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
+
+ {{ errorMessage }}
+
+
+ {{ errorMessage }}
+
@@ -356,8 +373,8 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n
-
- {{ nodeErrorMessage }}
+
+ {{ errorMessage }}
0) return value;
+ if (isRecord(value) && typeof value.message === 'string' && value.message.length > 0) {
+ return value.message;
+ }
+ return '';
+}
+
+/** MCP CallToolResult stores the message in structuredContent.error or text content. */
+function mcpErrorMessage(output: Record): string {
+ if (isRecord(output.structuredContent)) {
+ const fromStructured = errorTextFromValue(output.structuredContent.error);
+ if (fromStructured) return fromStructured;
+ }
+
+ if (!Array.isArray(output.content)) return '';
+ for (const block of output.content) {
+ if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') continue;
+ const text = block.text.trim();
+ if (!text) continue;
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (typeof parsed === 'string' && parsed.length > 0) return parsed;
+ if (isRecord(parsed)) {
+ const fromJson = errorTextFromValue(parsed.error);
+ if (fromJson) return fromJson;
+ }
+ } catch {
+ return text;
+ }
+ }
+ return '';
+}
+
+/**
+ * A tool/workflow/node call is failed when the runtime recorded an error
+ * outcome, or when a built-in tool returned a soft-failure payload instead
+ * of throwing. In-flight calls without output are not failed.
+ */
export function isErroredToolCallTimelineItem(item: TimelineItem): boolean {
- if (item.kind !== 'tool' && item.kind !== 'node' && item.kind !== 'workflow') return false;
+ if (item.kind !== 'tool' && item.kind !== 'workflow' && item.kind !== 'node') return false;
+ if (item.toolOutcome === 'error') return true;
+ if (item.toolOutcome === undefined && item.toolSuccess === false) return true;
+ if (!isRecord(item.toolOutput)) return false;
+
+ const { error, status, success, ok, isError } = item.toolOutput;
+ const hasErrorMessage =
+ (typeof error === 'string' && error.length > 0) ||
+ (isRecord(error) && typeof error.message === 'string' && error.message.length > 0);
+
return (
- item.toolOutcome === 'error' ||
- (item.toolOutcome === undefined && item.toolSuccess === false) ||
- (item.kind === 'workflow' && isRecord(item.toolOutput) && item.toolOutput.status === 'error')
+ hasErrorMessage ||
+ status === 'error' ||
+ status === 'failed' ||
+ success === false ||
+ ok === false ||
+ isError === true
);
}
+/** Extracts a human-readable error message from a failed item's tool output. */
+export function timelineItemErrorMessage(item: TimelineItem): string {
+ if (!isErroredToolCallTimelineItem(item)) return '';
+ const output = item.toolOutput;
+ if (!isRecord(output)) return '';
+ return errorTextFromValue(output.error) || mcpErrorMessage(output);
+}
+
export function hitlTimelineNameKey(item: TimelineItem): BaseTextKey | undefined {
if (item.hitlRequestType !== 'approval') return undefined;
if (item.kind === 'suspension') return 'agentSessions.timeline.approvalRequestForTool';