From 6155d8e8823f700dbb40d738b700a07349b0d089 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Tue, 18 Aug 2026 10:39:29 +0000 Subject: [PATCH 001/108] build: Re-pin mailsplit license election to 5.4.15 (no-changelog) (#36491) Co-authored-by: Claude Fable 5 --- scripts/licenses/license-overrides.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/licenses/license-overrides.json b/scripts/licenses/license-overrides.json index 769f8debf37..45a27d604f8 100644 --- a/scripts/licenses/license-overrides.json +++ b/scripts/licenses/license-overrides.json @@ -71,9 +71,9 @@ "elected": "MIT", "source": "Dual-licensed (MIT OR GPL-3.0-or-later) per https://github.com/Stuk/jszip/blob/main/LICENSE.markdown. n8n elects MIT; recorded so a copyleft policy gate reads MIT rather than the GPL alternative." }, - "pkg:npm/%40zone-eu/mailsplit@5.4.8": { + "pkg:npm/%40zone-eu/mailsplit@5.4.15": { "elected": "MIT", - "source": "Dual-licensed (MIT OR EUPL-1.1+) per https://github.com/zone-eu/mailsplit#license. n8n elects MIT; recorded so a copyleft policy gate reads MIT rather than the EUPL alternative." + "source": "Dual-licensed (MIT OR EUPL-1.1+) per https://github.com/zone-eu/mailsplit#license; re-verified for 5.4.15 (package.json license field unchanged). n8n elects MIT; recorded so a copyleft policy gate reads MIT rather than the EUPL alternative." } } } From 207c29f8e3e720435f75f270b56217e585547445 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Tue, 18 Aug 2026 10:47:43 +0000 Subject: [PATCH 002/108] fix(OpenAI Node): Filter non-chat models out of the chat model dropdown (#35785) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ricardo Espinoza --- .../__tests__/model-discovery.test.ts | 2 ++ .../model-discovery/__tests__/openai.test.ts} | 20 ++++++++++--- .../ai-utilities/src/model-discovery/index.ts | 3 +- .../src/model-discovery/providers/openai.ts | 16 ++++++++-- .../vendors/OpenAi/helpers/modelFiltering.ts | 30 ------------------- .../methods/__tests__/listSearch.test.ts | 2 ++ .../vendors/OpenAi/methods/listSearch.ts | 12 ++++++-- 7 files changed, 44 insertions(+), 41 deletions(-) rename packages/@n8n/{nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts => ai-utilities/src/model-discovery/__tests__/openai.test.ts} (63%) delete mode 100644 packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts diff --git a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts index a2fb138d160..7157b048b90 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/model-discovery.test.ts @@ -73,6 +73,8 @@ describe('model-discovery', () => { { id: 'gpt-5' }, { id: 'whisper-1' }, { id: 'dall-e-3' }, + { id: 'gpt-image-2' }, + { id: 'gpt-4o-transcribe' }, { id: 'text-embedding-3-small' }, { id: 'gpt-4o' }, ], diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/openai.test.ts similarity index 63% rename from packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts rename to packages/@n8n/ai-utilities/src/model-discovery/__tests__/openai.test.ts index 0b6ac55d733..3dea052c9e6 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/__tests__/modelFiltering.test.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/__tests__/openai.test.ts @@ -1,6 +1,6 @@ -import { shouldIncludeModel } from '../modelFiltering'; +import { shouldIncludeOpenAiModel } from '../providers/openai'; -describe('shouldIncludeModel', () => { +describe('shouldIncludeOpenAiModel', () => { const testCases: Array<{ modelId: string; officialAPI: boolean }> = [ // Excluded model types { modelId: 'babbage-002', officialAPI: false }, @@ -15,9 +15,21 @@ describe('shouldIncludeModel', () => { { modelId: 'gpt-4o-realtime-preview', officialAPI: false }, // infix check for -realtime { modelId: 'gpt-3.5-turbo-instruct', officialAPI: false }, // gpt-* with instruct + // Non-chat families named gpt-*, so caught by infix rather than prefix + { modelId: 'gpt-image-2', officialAPI: false }, + { modelId: 'chatgpt-image-latest', officialAPI: false }, + { modelId: 'gpt-transcribe', officialAPI: false }, + { modelId: 'gpt-4o-transcribe-diarize', officialAPI: false }, + // Every diarization model OpenAI ships today is also *-transcribe-diarize, + // so this pins the -diarize clause on its own + { modelId: 'gpt-4o-diarize', officialAPI: false }, + // Included models (standard chat models) { modelId: 'gpt-4', officialAPI: true }, { modelId: 'gpt-4o', officialAPI: true }, + // Unsupported on responses, but supported on chat/completions, which the + // OpenAI node's "Message a Model" uses, so it must stay selectable + { modelId: 'gpt-audio', officialAPI: true }, { modelId: 'o1-preview', officialAPI: true }, { modelId: 'ft:gpt-3.5-turbo', officialAPI: true }, // fine-tuned models @@ -28,7 +40,7 @@ describe('shouldIncludeModel', () => { describe('Custom API behavior', () => { it.each(testCases)('should include "$modelId"', ({ modelId }) => { - expect(shouldIncludeModel(modelId, true)).toBe(true); + expect(shouldIncludeOpenAiModel(modelId, true)).toBe(true); }); }); @@ -39,7 +51,7 @@ describe('shouldIncludeModel', () => { })); it.each(testCasesWithAction)('should $action "$modelId"', ({ modelId, officialAPI }) => { - expect(shouldIncludeModel(modelId, false)).toBe(officialAPI); + expect(shouldIncludeOpenAiModel(modelId, false)).toBe(officialAPI); }); }); }); diff --git a/packages/@n8n/ai-utilities/src/model-discovery/index.ts b/packages/@n8n/ai-utilities/src/model-discovery/index.ts index cd4b38c509c..eaddeb177ff 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/index.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/index.ts @@ -59,9 +59,10 @@ export { listGroqModels } from './providers/groq'; export { listMistralModels } from './providers/mistral'; export { listNvidiaModels } from './providers/nvidia'; export { - isOpenAiCustomEndpoint, listOpenAiModels, shouldIncludeOpenAiModel, + OFFICIAL_OPENAI_HOSTNAMES, + isOpenAiCustomEndpoint, } from './providers/openai'; export { listOpenRouterModels } from './providers/openrouter'; export { listVercelModels } from './providers/vercel'; diff --git a/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts b/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts index 667ae7fe748..1fdc845d9c7 100644 --- a/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts +++ b/packages/@n8n/ai-utilities/src/model-discovery/providers/openai.ts @@ -1,7 +1,7 @@ import { baseUrl, bearerHeaders, byName, getJson, idsToModels, type IdItem } from '../request'; import type { ListModelsFn } from '../types'; -const OFFICIAL_OPENAI_HOSTNAMES = ['api.openai.com', 'ai-assistant.n8n.io']; +export const OFFICIAL_OPENAI_HOSTNAMES = ['api.openai.com', 'ai-assistant.n8n.io']; export function isOpenAiCustomEndpoint(baseURL?: string): boolean { if (baseURL === undefined) return false; @@ -14,8 +14,11 @@ export function isOpenAiCustomEndpoint(baseURL?: string): boolean { } /** - * Source: LMChatOpenAi `methods/loadModels.ts` — on the official API, exclude - * non-chat model families; on custom (proxy/self-hosted) hosts include all. + * On the official API, exclude non-chat model families; on custom + * (proxy/self-hosted) hosts include all, since their naming is not OpenAI's. + * + * Single source of truth for every OpenAI model dropdown: the LMChatOpenAi + * sub-node, the OpenAI node's `modelSearch`, and the agents model catalog. */ export function shouldIncludeOpenAiModel(modelId: string, isCustomApi: boolean): boolean { if (isCustomApi) return true; @@ -24,6 +27,13 @@ export function shouldIncludeOpenAiModel(modelId: string, isCustomApi: boolean): modelId.startsWith('davinci') || modelId.startsWith('computer-use') || modelId.startsWith('dall-e') || + // Newer non-chat families are named gpt-*, so they clear the gpt- prefix and + // have to be matched by infix. Only list families unsupported on *both* + // chat/completions and responses: gpt-audio-* is absent on purpose, it works + // on chat/completions. + modelId.includes('-image') || + modelId.includes('-transcribe') || + modelId.includes('-diarize') || modelId.startsWith('text-embedding') || modelId.startsWith('tts') || modelId.includes('-tts') || diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts deleted file mode 100644 index 1ffc2eace31..00000000000 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/modelFiltering.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Determines whether a model should be included in the model list based on - * whether it's a custom API and the model's ID. - * - * @param modelId - The ID of the model to check - * @param isCustomAPI - Whether this is a custom API (not official OpenAI) - * @returns true if the model should be included, false otherwise - */ -export function shouldIncludeModel(modelId: string, isCustomAPI: boolean): boolean { - // For custom APIs, include all models - if (isCustomAPI) { - return true; - } - - // For official OpenAI API, exclude certain model types - return !( - modelId.startsWith('babbage') || - modelId.startsWith('davinci') || - modelId.startsWith('computer-use') || - modelId.startsWith('dall-e') || - modelId.startsWith('text-embedding') || - modelId.startsWith('tts') || - modelId.includes('-tts') || - modelId.startsWith('whisper') || - modelId.startsWith('omni-moderation') || - modelId.startsWith('sora') || - modelId.includes('-realtime') || - (modelId.startsWith('gpt-') && modelId.includes('instruct')) - ); -} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/__tests__/listSearch.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/__tests__/listSearch.test.ts index 7424890fc61..2ed59ce9d4e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/__tests__/listSearch.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/__tests__/listSearch.test.ts @@ -30,6 +30,8 @@ describe('modelSearch', () => { { id: 'babbage-002' }, { id: 'whisper-1' }, { id: 'dall-e-3' }, + { id: 'gpt-image-2' }, + { id: 'gpt-4o-transcribe' }, { id: 'gpt-4o-realtime-preview' }, ], }); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts index beaef9d5732..76b47734a3e 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts @@ -1,3 +1,7 @@ +import { + OFFICIAL_OPENAI_HOSTNAMES, + shouldIncludeOpenAiModel, +} from '@n8n/ai-utilities/model-discovery'; import type { IDataObject, ILoadOptionsFunctions, @@ -7,7 +11,6 @@ import type { import type { Assistant } from 'openai/resources/beta/assistants'; import type { Model } from 'openai/resources/models'; -import { shouldIncludeModel } from '../helpers/modelFiltering'; import { apiRequest } from '../transport'; export async function fileSearch( @@ -78,8 +81,11 @@ export async function modelSearch( ): Promise { const credentials = await this.getCredentials<{ url: string }>('openAiApi'); const url = credentials.url && new URL(credentials.url); - const isCustomAPI = !!(url && !['api.openai.com', 'ai-assistant.n8n.io'].includes(url.hostname)); - return await getModelSearch((model) => shouldIncludeModel(model.id, isCustomAPI))(this, filter); + const isCustomAPI = !!(url && !OFFICIAL_OPENAI_HOSTNAMES.includes(url.hostname)); + return await getModelSearch((model) => shouldIncludeOpenAiModel(model.id, isCustomAPI))( + this, + filter, + ); } export async function videoModelSearch( From 12cae8cbd9d0b31fe6de8022c0b6df951d005485 Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Tue, 18 Aug 2026 10:51:57 +0000 Subject: [PATCH 003/108] 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