From 12cae8cbd9d0b31fe6de8022c0b6df951d005485 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Tue, 18 Aug 2026 10:51:57 +0000 Subject: [PATCH] feat(ai-builder): Show tool hard and soft failures in agent session trace - timeline (#36405) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../frontend/@n8n/i18n/src/locales/en.json | 3 +- .../__tests__/SessionDetailPanel.spec.ts | 84 +++++++- .../__tests__/SessionTimelineChart.spec.ts | 46 ++++- .../__tests__/SessionTimelineRow.test.ts | 106 ++++++++++ .../__tests__/session-timeline.utils.spec.ts | 187 ++++++++++++++++++ .../agents/components/SessionDetailPanel.vue | 57 ++++-- .../features/agents/session-timeline.utils.ts | 67 ++++++- 7 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineRow.test.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index a5626168cc1..d04de21b973 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1522,7 +1522,8 @@ "agentSessions.timeline.memoryUpdated": "Memory updated", "agentSessions.timeline.openForm": "Open form", "agentSessions.timeline.workflowError": "Workflow call did not produce an execution", - "agentSessions.timeline.nodeError": "Tool experienced an error", + "agentSessions.timeline.toolError": "Tool call failed", + "agentSessions.timeline.failed": "Failed", "agentSessions.timeline.filter": "Filter", "agentSessions.timeline.clearFilter": "Clear", "agentSessions.timeline.suspended": "Suspended", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts index 5fec982d570..2e26044e99b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionDetailPanel.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ import { describe, it, expect, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { createRouter, createMemoryHistory, type Router } from 'vue-router'; @@ -137,6 +136,24 @@ describe('SessionDetailPanel — workflow branches', () => { expect(w.find('[data-test-id="wf-log-viewer"]').exists()).toBe(true); }); + it('shows a soft-failure callout alongside the workflow execution viewer', () => { + const w = mountIt({ + kind: 'workflow', + executionId: 'e1', + timestamp: 0, + workflowId: 'wf-1', + workflowName: 'WF', + workflowExecutionId: 'exec-1', + toolSuccess: true, + toolOutput: { status: 'error', error: 'Node X failed' }, + }); + const callout = w.find('[data-test-id="workflow-error-callout"]'); + expect(callout.exists()).toBe(true); + expect(callout.text()).toContain('Node X failed'); + expect(w.find('[data-test-id="wf-log-viewer"]').exists()).toBe(true); + expect(w.find('[data-test-id="detail-tool-error-badge"]').exists()).toBe(true); + }); + it('opens the full execution in a new tab when the header button is clicked', async () => { const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const w = mountIt({ @@ -282,6 +299,65 @@ describe('SessionDetailPanel — other kinds', () => { expect(w.find('[data-test-id="tool-io-view"]').exists()).toBe(false); }); + it('shows a generic tool soft-failure message and header icon', () => { + const w = mountIt({ + kind: 'tool', + executionId: 'e1', + timestamp: 0, + toolName: 'load_skill', + toolSuccess: true, + toolOutput: { success: false, error: 'Skill not found' }, + }); + const callout = w.find('[data-test-id="tool-error-callout"]'); + expect(callout.exists()).toBe(true); + expect(callout.text()).toContain('Skill not found'); + expect(w.find('[data-test-id="detail-tool-error-badge"]').exists()).toBe(true); + }); + + it('shows a nested integration error message', () => { + const w = mountIt({ + kind: 'tool', + executionId: 'e1', + timestamp: 0, + toolName: 'slack_action', + toolSuccess: true, + toolOutput: { + ok: false, + error: { code: 'NO_MESSAGE_CONTEXT', message: 'No message context' }, + }, + }); + expect(w.find('[data-test-id="tool-error-callout"]').text()).toContain('No message context'); + }); + + it('shows an MCP structuredContent error message', () => { + const w = mountIt({ + kind: 'tool', + executionId: 'e1', + timestamp: 0, + toolName: 'github_create_issue', + toolSuccess: true, + toolOutput: { + isError: true, + content: [{ type: 'text', text: '{"error":"Repository not found"}' }], + structuredContent: { error: 'Repository not found' }, + }, + }); + expect(w.find('[data-test-id="tool-error-callout"]').text()).toContain('Repository not found'); + }); + + it('does not show a failure callout or header icon for a successful tool', () => { + const w = mountIt({ + kind: 'tool', + executionId: 'e1', + timestamp: 0, + toolName: 'http', + toolSuccess: true, + toolOutput: { ok: true }, + }); + expect(w.find('[data-test-id="tool-error-callout"]').exists()).toBe(false); + expect(w.find('[data-test-id="detail-tool-error-badge"]').exists()).toBe(false); + }); + it('renders the ToolIoView for node tool calls', () => { const w = mountIt({ kind: 'node', @@ -312,9 +388,7 @@ describe('SessionDetailPanel — other kinds', () => { }); const callout = w.find('[data-test-id="node-error-callout"]'); expect(callout.exists()).toBe(true); - expect(callout.text()).toContain( - 'Tool experienced an error: Node does not have any credentials set', - ); + expect(callout.text()).toContain('Tool call failed: Node does not have any credentials set'); expect(w.get('[data-test-id="detail-tool-error-badge"]').text()).toBe('Error'); }); @@ -333,7 +407,7 @@ describe('SessionDetailPanel — other kinds', () => { }); const callout = w.find('[data-test-id="node-error-callout"]'); expect(callout.exists()).toBe(true); - expect(callout.text()).toContain('Tool experienced an error'); + expect(callout.text()).toContain('Tool call failed'); expect(callout.text()).not.toContain(':'); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts index 8340a07872b..8779f27876d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineChart.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ import { describe, it, expect, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import SessionTimelineChart from '../components/SessionTimelineChart.vue'; @@ -116,6 +115,51 @@ describe('SessionTimelineChart', () => { expect(blocks[2].element.getAttribute('data-selected')).toBe('true'); }); + it('marks a generic tool soft-failure block as failed', () => { + const w = mountChart({ + items: [ + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { success: false, error: 'boom' }, + }), + ], + }); + const block = w.get('[data-test-id="timeline-block"]'); + expect(block.attributes('data-error')).toBe('true'); + expect(block.classes()).toContain('error'); + }); + + it('marks a workflow soft-failure block as failed', () => { + const w = mountChart({ + items: [ + item({ + kind: 'workflow', + toolSuccess: true, + toolOutput: { status: 'error', error: 'boom' }, + }), + ], + }); + const block = w.get('[data-test-id="timeline-block"]'); + expect(block.attributes('data-error')).toBe('true'); + expect(block.classes()).toContain('error'); + }); + + it('does not mark a successful tool block as failed', () => { + const w = mountChart({ + items: [ + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { ok: true }, + }), + ], + }); + const block = w.get('[data-test-id="timeline-block"]'); + expect(block.attributes('data-error')).toBeUndefined(); + expect(block.classes()).not.toContain('error'); + }); + it('renders the localized "Idle" pill text inside each idle segment', () => { const w = mountChart({ idleRanges: [{ start: 1500, end: 2000 }] }); const idle = w.find('[data-test-id="timeline-idle"]'); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineRow.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineRow.test.ts new file mode 100644 index 00000000000..64ddc2aa0dc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/SessionTimelineRow.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep, mock reads */ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import type { TimelineItem } from '../session-timeline.types'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), +})); + +vi.mock('vue-router', () => ({ + useRouter: () => ({ resolve: () => ({ href: '/wf/1' }) }), +})); + +vi.mock('@/app/utils/formatters/dateFormatter', () => ({ + convertToDisplayDate: () => ({ date: '', time: '00:00' }), +})); + +vi.mock('@n8n/utils/string/truncate', () => ({ + truncate: (value: string) => value, +})); + +vi.mock('../utils/delegate-tool', () => ({ + delegateLabel: () => 'Sub-agent', + isDelegateSubAgentTool: () => false, +})); + +vi.mock('../utils/toolDisplayName', () => ({ + formatToolNameForDisplay: (name: string) => name, + resolveToolNameForDisplay: (name: string) => name, +})); + +const STUBS = { + N8nTooltip: { template: '' }, + N8nIcon: { + props: ['icon', 'size'], + template: + '', + }, + N8nBadge: { + props: ['theme', 'size'], + template: '', + }, + SessionTimelinePill: { + props: ['kind'], + template: '', + }, +}; + +function item(partial: Partial): TimelineItem { + return { + kind: 'tool', + executionId: 'e1', + timestamp: 1000, + toolName: 'http', + ...partial, + } as TimelineItem; +} + +async function renderComponent(it: TimelineItem) { + const { default: SessionTimelineRow } = await import('../components/SessionTimelineRow.vue'); + return mount(SessionTimelineRow, { + props: { item: it, selected: false }, + global: { stubs: STUBS }, + }); +} + +describe('SessionTimelineRow', () => { + it('renders the failure icon for a generic tool soft-failure', async () => { + const wrapper = await renderComponent( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { success: false, error: 'boom' }, + }), + ); + expect(wrapper.find('[data-test-id="timeline-tool-error-badge"]').exists()).toBe(true); + }, 30_000); + + it('renders the failure icon for a workflow soft-failure (success true, status error)', async () => { + const wrapper = await renderComponent( + item({ + kind: 'workflow', + toolSuccess: true, + toolOutput: { status: 'error', error: 'node X failed' }, + }), + ); + expect(wrapper.find('[data-test-id="timeline-tool-error-badge"]').exists()).toBe(true); + }); + + it('does not render the failure icon for a successful tool call', async () => { + const wrapper = await renderComponent( + item({ kind: 'tool', toolSuccess: true, toolOutput: { ok: true } }), + ); + expect(wrapper.find('[data-test-id="timeline-tool-error-badge"]').exists()).toBe(false); + }); + + it('does not render the failure icon for an in-flight tool call', async () => { + const wrapper = await renderComponent(item({ kind: 'tool', toolSuccess: undefined })); + expect(wrapper.find('[data-test-id="timeline-tool-error-badge"]').exists()).toBe(false); + }); + + it('does not render the failure icon for non-tool kinds', async () => { + const wrapper = await renderComponent(item({ kind: 'user', toolSuccess: false })); + expect(wrapper.find('[data-test-id="timeline-tool-error-badge"]').exists()).toBe(false); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts index b8e2c41715c..b431ae77f31 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/session-timeline.utils.spec.ts @@ -10,7 +10,9 @@ import { flattenExecutionsToTimelineItems, itemStatusFilterKey, matchesSearch, + isErroredToolCallTimelineItem, matchesTimelineFilters, + timelineItemErrorMessage, } from '../session-timeline.utils'; import type { TimelineItem } from '../session-timeline.types'; @@ -663,3 +665,188 @@ describe('flattenExecutionsToTimelineItems', () => { expect(items.map((i) => i.content)).toEqual(['a', 'b']); }); }); + +describe('isErroredToolCallTimelineItem', () => { + it.each([ + ['runtime hard failure', { kind: 'tool', toolSuccess: false }], + ['toolOutcome error', { kind: 'tool', toolOutcome: 'error' }], + ['generic error string', { kind: 'tool', toolSuccess: true, toolOutput: { error: 'boom' } }], + [ + 'integration error object', + { + kind: 'tool', + toolSuccess: true, + toolOutput: { ok: false, error: { code: 'ACTION_FAILED', message: 'Action failed' } }, + }, + ], + [ + 'workflow error status', + { kind: 'workflow', toolSuccess: true, toolOutput: { status: 'error' } }, + ], + [ + 'delegate failed status', + { kind: 'tool', toolSuccess: true, toolOutput: { status: 'failed' } }, + ], + [ + 'workspace success false', + { kind: 'tool', toolSuccess: true, toolOutput: { success: false } }, + ], + ['integration ok false', { kind: 'tool', toolSuccess: true, toolOutput: { ok: false } }], + ['MCP isError true', { kind: 'tool', toolSuccess: true, toolOutput: { isError: true } }], + ] satisfies Array<[string, Partial]>)('flags %s', (_label, partial) => { + expect(isErroredToolCallTimelineItem(item(partial))).toBe(true); + }); + + it.each([ + ['empty error string', { toolOutput: { error: '' } }], + ['empty nested error message', { toolOutput: { error: { message: '' } } }], + ['success status', { toolOutput: { status: 'success' } }], + ['success true', { toolOutput: { success: true } }], + ['ok true', { toolOutput: { ok: true } }], + ['isError false', { toolOutput: { isError: false } }], + ['non-record output', { toolOutput: 'failed' }], + ['in-flight call', { toolSuccess: undefined, toolOutput: undefined }], + ] satisfies Array<[string, Partial]>)('does not flag %s', (_label, partial) => { + expect( + isErroredToolCallTimelineItem(item({ kind: 'tool', toolSuccess: true, ...partial })), + ).toBe(false); + }); + + it('does not flag user/agent/suspension kinds', () => { + expect(isErroredToolCallTimelineItem(item({ kind: 'user', toolSuccess: false }))).toBe(false); + expect(isErroredToolCallTimelineItem(item({ kind: 'agent', toolSuccess: false }))).toBe(false); + expect(isErroredToolCallTimelineItem(item({ kind: 'suspension', toolSuccess: false }))).toBe( + false, + ); + }); +}); + +describe('timelineItemErrorMessage', () => { + it('extracts the error message from a thrown tool call', () => { + expect( + timelineItemErrorMessage( + item({ kind: 'tool', toolSuccess: false, toolOutput: { error: 'timed out' } }), + ), + ).toBe('timed out'); + }); + + it('extracts the error message from a workflow soft-failure', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'workflow', + toolSuccess: true, + toolOutput: { status: 'error', error: 'node X failed' }, + }), + ), + ).toBe('node X failed'); + }); + + it('extracts a nested integration error message', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { + ok: false, + error: { code: 'NO_MESSAGE_CONTEXT', message: 'No message context' }, + }, + }), + ), + ).toBe('No message context'); + }); + + it('extracts MCP structuredContent.error', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { + isError: true, + content: [{ type: 'text', text: '{"error":"Workflow not found"}' }], + structuredContent: { error: 'Workflow not found' }, + }, + }), + ), + ).toBe('Workflow not found'); + }); + + it('extracts MCP structuredContent.error.message', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { + isError: true, + content: [{ type: 'text', text: 'ignored' }], + structuredContent: { error: { message: 'Tool execution failed' } }, + }, + }), + ), + ).toBe('Tool execution failed'); + }); + + it('extracts MCP text content when structuredContent has no error', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { + isError: true, + content: [{ type: 'text', text: 'Access denied by user' }], + }, + }), + ), + ).toBe('Access denied by user'); + }); + + it('extracts MCP JSON text envelopes without dumping extra payload fields', () => { + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ error: 'Selector not found', snapshot: '' }), + }, + ], + }, + }), + ), + ).toBe('Selector not found'); + }); + + it('returns empty string when no error message is present', () => { + expect( + timelineItemErrorMessage(item({ kind: 'tool', toolSuccess: false, toolOutput: {} })), + ).toBe(''); + expect( + timelineItemErrorMessage( + item({ kind: 'workflow', toolSuccess: true, toolOutput: { status: 'error' } }), + ), + ).toBe(''); + expect( + timelineItemErrorMessage( + item({ + kind: 'tool', + toolSuccess: true, + toolOutput: { isError: true, content: [{ type: 'image', data: 'abc' }] }, + }), + ), + ).toBe(''); + }); + + it('returns empty string for non-failed items', () => { + expect( + timelineItemErrorMessage(item({ kind: 'tool', toolSuccess: true, toolOutput: { ok: true } })), + ).toBe(''); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue index 18a1561c38c..7cdba9b165f 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/SessionDetailPanel.vue @@ -25,8 +25,10 @@ import ToolIoView from './ToolIoView.vue'; import type { TimelineItem } from '../session-timeline.types'; import { hitlTimelineName, + isErroredToolCallTimelineItem, isSubAgentTimelineItem, linkedToolDisplayName, + timelineItemErrorMessage, timelineItemStatus, } from '../session-timeline.utils'; import { delegateLabel } from '../utils/delegate-tool'; @@ -197,23 +199,22 @@ const headerIcon = computed((): IconName => { return 'clock'; }); -const nodeErrorMessage = computed((): string => { +const isFailed = computed((): boolean => + props.item ? isErroredToolCallTimelineItem(props.item) : false, +); + +/** + * Error message for a failed tool/workflow/node call. It surfaces a string, + * nested `toolOutput.error.message`, or MCP `structuredContent.error` / text + * content when available. Soft-failure payloads are detected in + * `isErroredToolCallTimelineItem`. + */ +const errorMessage = computed((): string => { const item = props.item; - if ( - !item || - item.kind !== 'node' || - (item.toolOutcome !== 'error' && - !(item.toolOutcome === undefined && item.toolSuccess === false)) - ) { - return ''; - } - const prefix = i18n.baseText('agentSessions.timeline.nodeError'); - const output = item.toolOutput; - if (output && typeof output === 'object' && 'error' in output) { - const err = (output as { error: unknown }).error; - if (typeof err === 'string' && err.length > 0) return `${prefix}: ${err}`; - } - return prefix; + if (!item || !isFailed.value) return ''; + const prefix = i18n.baseText('agentSessions.timeline.toolError'); + const message = timelineItemErrorMessage(item); + return message ? `${prefix}: ${message}` : prefix; }); const workflowFormOutput = computed((): { formUrl: string; message: string } | null => { @@ -299,6 +300,14 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n