fix(core): Surface failures in agent sessions (#36492)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-08-18 13:07:10 +00:00
committed by GitHub
parent 697d53c8d0
commit d551999c6c
33 changed files with 829 additions and 79 deletions
+2 -1
View File
@@ -16,7 +16,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.agent_eval_rating](public.agent_eval_rating.md) | 8 | | BASE TABLE |
| [public.agent_eval_result](public.agent_eval_result.md) | 15 | | BASE TABLE |
| [public.agent_eval_run](public.agent_eval_run.md) | 14 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 20 | | BASE TABLE |
| [public.agent_execution](public.agent_execution.md) | 21 | | BASE TABLE |
| [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE |
| [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE |
| [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE |
@@ -441,6 +441,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model
@@ -10,6 +10,7 @@
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| duration | integer | 0 | false | | | |
| error | text | | true | | | |
| failureSummary | json | | true | | | Execution failure projection as {count, latest} for session list queries |
| hitlStatus | varchar(16) | | true | | | |
| id | varchar(36) | | false | | | |
| model | varchar(255) | | true | | | |
@@ -64,6 +65,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model
@@ -103,6 +103,7 @@ erDiagram
timestamp_3__with_time_zone createdAt
integer duration
text error
json failureSummary
varchar_16_ hitlStatus
varchar_36_ id
varchar_255_ model
+2 -1
View File
@@ -16,7 +16,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [agent_eval_rating](agent_eval_rating.md) | 8 | | table |
| [agent_eval_result](agent_eval_result.md) | 15 | | table |
| [agent_eval_run](agent_eval_run.md) | 14 | | table |
| [agent_execution](agent_execution.md) | 20 | | table |
| [agent_execution](agent_execution.md) | 21 | | table |
| [agent_execution_threads](agent_execution_threads.md) | 17 | | table |
| [agent_files](agent_files.md) | 10 | | table |
| [agent_history](agent_history.md) | 9 | | table |
@@ -428,6 +428,7 @@ erDiagram
datetime_3_ createdAt
INTEGER duration
TEXT error
TEXT failureSummary
varchar_16_ hitlStatus
varchar_36_ id PK
varchar_255_ model
+6 -4
View File
@@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))), CONSTRAINT "CHK_agent_execution_status" CHECK ("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted')), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "startedAt" datetime(3), "stoppedAt" datetime(3), "duration" integer NOT NULL DEFAULT (0), "userMessage" text, "model" varchar(255), "promptTokens" integer, "completionTokens" integer, "totalTokens" integer, "cost" real, "timeline" text, "error" text, "hitlStatus" varchar(16), "source" varchar(32), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "storedAt" varchar(2) NOT NULL DEFAULT ('db'), "attachments" text, "failureSummary" text, CONSTRAINT "CHK_agent_execution_storedAt" CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))), CONSTRAINT "CHK_agent_execution_hitlStatus" CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))), CONSTRAINT "CHK_agent_execution_status" CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))), CONSTRAINT "FK_add2432fb6034cc18b6af299dce" FOREIGN KEY ("threadId") REFERENCES "agent_execution_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
```
</details>
@@ -21,6 +21,7 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| duration | INTEGER | 0 | false | | | |
| error | TEXT | | true | | | |
| failureSummary | TEXT | | true | | | |
| hitlStatus | varchar(16) | | true | | | |
| id | varchar(36) | | false | | | |
| model | varchar(255) | | true | | | |
@@ -40,9 +41,9 @@ CREATE TABLE "agent_execution" ("id" varchar(36) PRIMARY KEY NOT NULL, "threadId
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK (("storedAt" IN ('db', 'fs', 's3', 'az'))) |
| - | CHECK | CHECK ((((("hitlStatus" IN ('suspended', 'resumed')))))) |
| - | CHECK | CHECK ("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted')) |
| - | CHECK | CHECK ((("storedAt" IN ('db', 'fs', 's3', 'az')))) |
| - | CHECK | CHECK (((((("hitlStatus" IN ('suspended', 'resumed'))))))) |
| - | CHECK | CHECK (("status" IN ('running', 'success', 'error', 'cancelled', 'interrupted'))) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES agent_execution_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_agent_execution_1 | PRIMARY KEY | PRIMARY KEY (id) |
@@ -69,6 +70,7 @@ erDiagram
datetime_3_ createdAt
INTEGER duration
TEXT error
TEXT failureSummary
varchar_16_ hitlStatus
varchar_36_ id PK
varchar_255_ model
+1
View File
@@ -104,6 +104,7 @@ erDiagram
datetime_3_ createdAt
INTEGER duration
TEXT error
TEXT failureSummary
varchar_16_ hitlStatus
varchar_36_ id PK
varchar_255_ model
@@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddAgentExecutionFailureSummary1787040021605 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(
'agent_execution',
[
column('failureSummary').json.comment(
'Execution failure projection as {count, latest} for session list queries',
),
],
{ recreatesOnSqlite: true },
);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('agent_execution', ['failureSummary'], { recreatesOnSqlite: true });
}
}
@@ -294,7 +294,7 @@ describe('AgentExecutionService', () => {
output: {},
startTime: 0,
endTime: 123,
success: true,
success: false,
},
],
});
@@ -316,7 +316,19 @@ describe('AgentExecutionService', () => {
expect(agentExecutionRepository.updateIfRunning).toHaveBeenCalledWith(
'execution-1',
expect.objectContaining({ timeline: null, storedAt: 'fs' }),
expect.objectContaining({
timeline: null,
storedAt: 'fs',
failureSummary: {
count: 1,
latest: {
kind: 'tool',
name: 'lookup',
message: null,
occurredAt: 123,
},
},
}),
);
expect(agentExecutionLogStore.write).toHaveBeenCalledWith(
{ agentId: 'agent-1', threadId: 'thread-1', executionId: 'execution-1' },
@@ -385,6 +397,7 @@ describe('AgentExecutionService', () => {
status: 'success',
timeline: record.timeline,
storedAt: 'db',
failureSummary: null,
}),
);
});
@@ -738,6 +751,7 @@ describe('AgentExecutionService', () => {
status: 'cancelled',
timeline: record.timeline,
storedAt: 'db',
failureSummary: null,
}),
);
expect(telemetry.trackAgentTurnFinished).toHaveBeenCalledWith(
@@ -786,12 +800,65 @@ describe('AgentExecutionService', () => {
timeline: partial,
storedAt: 'db',
error: expect.stringContaining('interrupted'),
failureSummary: {
count: 1,
latest: {
kind: 'execution',
name: null,
message: expect.stringContaining('interrupted'),
occurredAt: expect.any(Number),
},
},
}),
);
expect(agentExecutionLogStore.write).not.toHaveBeenCalled();
});
});
describe('getThreads', () => {
it('returns latest run statuses with recovered failures marked as errors', async () => {
const failedThread = makeThread({ id: 'thread-failed' });
const cleanThread = makeThread({ id: 'thread-clean' });
const runningThread = makeThread({ id: 'thread-running' });
const emptyThread = makeThread({ id: 'thread-empty' });
const failureSummary = {
count: 2,
latest: {
kind: 'tool' as const,
name: 'lookup',
message: 'request failed',
occurredAt: 20,
executionId: 'execution-2',
},
};
agentExecutionThreadRepository.findByProjectIdPaginated.mockResolvedValue({
threads: [failedThread, cleanThread, runningThread, emptyThread],
nextCursor: null,
});
agentExecutionRepository.findFirstUserMessageByThreadIds.mockResolvedValue(new Map());
agentExecutionRepository.findFirstSourceByThreadIds.mockResolvedValue(new Map());
agentExecutionRepository.findFailureSummariesByThreadIds.mockResolvedValue(
new Map([[failedThread.id, failureSummary]]),
);
agentExecutionRepository.findLatestStatusesByThreadIds.mockResolvedValue(
new Map([
[failedThread.id, 'success'],
[cleanThread.id, 'success'],
[runningThread.id, 'running'],
]),
);
const result = await service.getThreads('project-1', 'agent-1', 20);
expect(result.threads).toEqual([
expect.objectContaining({ id: failedThread.id, failureSummary, status: 'error' }),
expect.objectContaining({ id: cleanThread.id, failureSummary: null, status: 'success' }),
expect.objectContaining({ id: runningThread.id, failureSummary: null, status: 'running' }),
expect.objectContaining({ id: emptyThread.id, failureSummary: null, status: null }),
]);
});
});
describe('getThreadDetail', () => {
it('returns thread executions after ownership validation', async () => {
const thread = makeThread();
@@ -0,0 +1,125 @@
import type { TimelineEvent } from '../execution-recorder';
import { computeExecutionFailureSummary } from '../utils/execution-failure-summary';
function failedCall(
kind: 'tool' | 'node' | 'workflow',
overrides: Partial<Extract<TimelineEvent, { type: 'tool-call' }>> = {},
): Extract<TimelineEvent, { type: 'tool-call' }> {
return {
type: 'tool-call',
kind,
name: 'fallback_name',
toolCallId: 'call-1',
input: {},
output: { error: 'request failed' },
startTime: 10,
endTime: 20,
success: false,
...overrides,
};
}
describe('computeExecutionFailureSummary', () => {
it.each([
['tool', {}, 'fallback_name', 'request failed'],
['node', { nodeDisplayName: 'Lookup customer' }, 'Lookup customer', 'request failed'],
['workflow', { workflowName: 'Enrich account' }, 'Enrich account', 'request failed'],
[
'tool',
{
name: 'delegate_subagent',
success: true,
output: { status: 'failed', error: 'child failed' },
},
'delegate_subagent',
'child failed',
],
] as const)(
'projects a completed failed %s call',
(kind, overrides, expectedName, expectedMessage) => {
const summary = computeExecutionFailureSummary({
timeline: [failedCall(kind, overrides)],
status: 'success',
error: null,
stoppedAt: 30,
});
expect(summary).toEqual({
count: 1,
latest: {
kind,
name: expectedName,
message: expectedMessage,
occurredAt: 20,
},
});
},
);
it('counts workflow and execution failure scopes once and keeps the latest truncated message', () => {
const summary = computeExecutionFailureSummary({
timeline: [
failedCall('workflow', {
success: true,
output: { status: 'error', error: 'workflow failed' },
}),
],
status: 'error',
error: 'x'.repeat(500),
stoppedAt: 30,
});
expect(summary).toEqual({
count: 2,
latest: {
kind: 'execution',
name: null,
message: 'x'.repeat(400),
occurredAt: 30,
},
});
});
it.each([
{
name: 'open call',
timeline: [failedCall('tool', { endTime: 0 })],
status: 'success' as const,
},
{
name: 'declined call',
timeline: [failedCall('node', { output: { declined: true, error: 'not approved' } })],
status: 'success' as const,
},
{ name: 'cancelled execution', timeline: [], status: 'cancelled' as const },
{ name: 'clean success', timeline: [], status: 'success' as const },
])('does not project a $name', ({ timeline, status }) => {
expect(
computeExecutionFailureSummary({
timeline,
status,
error: null,
stoppedAt: 30,
}),
).toBeNull();
});
it('projects interrupted executions as failures', () => {
expect(
computeExecutionFailureSummary({
timeline: [],
status: 'interrupted',
error: 'Agent execution was interrupted.',
stoppedAt: 30,
}),
).toEqual({
count: 1,
latest: {
kind: 'execution',
name: null,
message: 'Agent execution was interrupted.',
occurredAt: 30,
},
});
});
});
@@ -14,7 +14,7 @@ import {
} from './agent-chat-attachment.service';
import { AgentExecutionUpdateBroadcaster } from './agent-execution-update-broadcaster';
import { AgentExecutionThread } from './entities/agent-execution-thread.entity';
import { AgentExecution } from './entities/agent-execution.entity';
import { AgentExecution, type AgentExecutionStatus } from './entities/agent-execution.entity';
import type { MessageRecord, TimelineEvent } from './execution-recorder';
import { AgentExecutionLogStore } from './execution-log/agent-execution-log-store';
import { N8nMemory } from './integrations/n8n-memory';
@@ -24,6 +24,10 @@ import {
AgentExecutionRepository,
type RunningAgentExecution,
} from './repositories/agent-execution.repository';
import {
computeExecutionFailureSummary,
type ThreadFailureSummary,
} from './utils/execution-failure-summary';
export interface RecordMessageParams {
threadId: string;
@@ -70,6 +74,8 @@ export interface ThreadListItem extends Omit<AgentExecutionThread, 'generateId'
firstMessage: string | null;
/** Earliest non-null execution source for the thread (e.g. slack, telegram). */
source: string | null;
failureSummary: ThreadFailureSummary | null;
status: AgentExecutionStatus | null;
}
const TIMELINE_SNAPSHOT_RETRY_DELAY_MS = 1_000;
@@ -123,6 +129,7 @@ export class AgentExecutionService {
timeline: null,
storedAt: 'db',
error: null,
failureSummary: null,
hitlStatus: null,
source: params.source ?? null,
attachments: params.attachments?.length ? params.attachments : null,
@@ -147,6 +154,13 @@ export class AgentExecutionService {
async finalizeExecution(executionId: string, params: RecordMessageParams): Promise<string> {
const { record, hitlStatus } = params;
const status = executionStatus(record);
const stoppedAt = new Date(record.startTime + record.duration);
const failureSummary = computeExecutionFailureSummary({
timeline: record.timeline,
status,
error: record.error,
stoppedAt: stoppedAt.getTime(),
});
let storedAt: AgentExecution['storedAt'] =
record.timeline.length > 0 ? this.storageConfig.modeTag : 'db';
@@ -166,7 +180,7 @@ export class AgentExecutionService {
const finalized = await this.agentExecutionRepository.updateIfRunning(executionId, {
status,
stoppedAt: new Date(record.startTime + record.duration),
stoppedAt,
duration: record.duration,
model: record.model,
promptTokens: record.usage?.promptTokens ?? null,
@@ -176,6 +190,7 @@ export class AgentExecutionService {
timeline: storedAt === 'db' && record.timeline.length > 0 ? record.timeline : null,
storedAt,
error: record.error,
failureSummary,
hitlStatus: hitlStatus ?? null,
});
if (!finalized) return executionId;
@@ -200,6 +215,7 @@ export class AgentExecutionService {
async finalizeInterruptedExecution(execution: RunningAgentExecution): Promise<boolean> {
const timeline = execution.timeline ?? [];
const stoppedAt = new Date();
const error = 'Agent execution was interrupted by a process restart.';
const duration = execution.startedAt
? Math.max(0, stoppedAt.getTime() - execution.startedAt.getTime())
: 0;
@@ -209,7 +225,13 @@ export class AgentExecutionService {
duration,
timeline: timeline.length > 0 ? timeline : null,
storedAt: 'db',
error: 'Agent execution was interrupted by a process restart.',
error,
failureSummary: computeExecutionFailureSummary({
timeline,
status: 'interrupted',
error,
stoppedAt: stoppedAt.getTime(),
}),
});
if (finalized) void this.notifyInterruptedExecution(execution);
return finalized;
@@ -494,9 +516,11 @@ export class AgentExecutionService {
}
const threadIds = page.threads.map((t) => t.id);
const [messageMap, sourceMap] = await Promise.all([
const [messageMap, sourceMap, failureSummaryMap, latestStatusMap] = await Promise.all([
this.agentExecutionRepository.findFirstUserMessageByThreadIds(threadIds),
this.agentExecutionRepository.findFirstSourceByThreadIds(threadIds),
this.agentExecutionRepository.findFailureSummariesByThreadIds(threadIds),
this.agentExecutionRepository.findLatestStatusesByThreadIds(threadIds),
]);
return {
@@ -505,6 +529,8 @@ export class AgentExecutionService {
...t,
firstMessage: messageMap.get(t.id) ?? null,
source: sourceMap.get(t.id) ?? null,
failureSummary: failureSummaryMap.get(t.id) ?? null,
status: sessionStatus(latestStatusMap.get(t.id), failureSummaryMap.has(t.id)),
})),
};
}
@@ -584,6 +610,14 @@ export class AgentExecutionService {
}
}
function sessionStatus(
latestStatus: AgentExecutionStatus | undefined,
hasFailureSummary: boolean,
): AgentExecutionStatus | null {
if (!latestStatus) return null;
return latestStatus === 'success' && hasFailureSummary ? 'error' : latestStatus;
}
function cleanUserMessage(message: string | null, agentName: string): string | null {
if (message === null) return null;
const cleaned = message
@@ -8,6 +8,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from '@n8n/typeorm';
import { AgentExecutionThread } from './agent-execution-thread.entity';
import type { TimelineEvent } from '../execution-recorder';
import type { AgentExecutionFailureSummary } from '../utils/execution-failure-summary';
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
export type AgentExecutionHitlStatus = 'suspended' | 'resumed';
@@ -84,6 +85,9 @@ export class AgentExecution extends WithTimestampsAndStringId {
@Column({ type: 'text', nullable: true })
error: string | null;
@JsonColumn({ nullable: true })
failureSummary: AgentExecutionFailureSummary | null;
@Column({ type: 'varchar', length: 16, nullable: true })
hitlStatus: AgentExecutionHitlStatus | null;
@@ -2,7 +2,8 @@ import { Service } from '@n8n/di';
import { DataSource, IsNull, Not, Repository } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { AgentExecution } from '../entities/agent-execution.entity';
import { AgentExecution, type AgentExecutionStatus } from '../entities/agent-execution.entity';
import type { ThreadFailureSummary } from '../utils/execution-failure-summary';
export type RunningAgentExecution = Pick<
AgentExecution,
@@ -11,7 +12,7 @@ export type RunningAgentExecution = Pick<
type AgentExecutionFinalizationValues = Pick<
AgentExecution,
'status' | 'stoppedAt' | 'duration' | 'timeline' | 'storedAt' | 'error'
'status' | 'stoppedAt' | 'duration' | 'timeline' | 'storedAt' | 'error' | 'failureSummary'
> &
Partial<
Pick<
@@ -122,6 +123,55 @@ export class AgentExecutionRepository extends Repository<AgentExecution> {
return new Map(rows.map((r) => [r.threadId, r.source]));
}
async findLatestStatusesByThreadIds(
threadIds: string[],
): Promise<Map<string, AgentExecutionStatus>> {
if (threadIds.length === 0) return new Map();
const tableName = this.metadata.tablePath;
const rows = await this.createQueryBuilder('e')
.select(['e."threadId" AS "threadId"', 'e."status" AS "status"'])
.where('e."threadId" IN (:...threadIds)', { threadIds })
.andWhere(
`e.id = (SELECT e2.id FROM ${tableName} e2 ` +
'WHERE e2."threadId" = e."threadId" ' +
'ORDER BY e2."createdAt" DESC, e2.id DESC LIMIT 1)',
)
.getRawMany<{ threadId: string; status: AgentExecutionStatus }>();
return new Map(rows.map((row) => [row.threadId, row.status]));
}
async findFailureSummariesByThreadIds(
threadIds: string[],
): Promise<Map<string, ThreadFailureSummary>> {
if (threadIds.length === 0) return new Map();
const executions = await this.createQueryBuilder('e')
.select(['e.id', 'e.threadId', 'e.failureSummary'])
.where('e."threadId" IN (:...threadIds)', { threadIds })
.andWhere('e."failureSummary" IS NOT NULL')
.getMany();
const summaries = new Map<string, ThreadFailureSummary>();
for (const execution of executions) {
const summary = execution.failureSummary;
if (!summary) continue;
const latest = { ...summary.latest, executionId: execution.id };
const current = summaries.get(execution.threadId);
if (!current) {
summaries.set(execution.threadId, { count: summary.count, latest });
continue;
}
current.count += summary.count;
if (latest.occurredAt >= current.latest.occurredAt) current.latest = latest;
}
return summaries;
}
/**
* Suspended runs in a thread that don't yet have a `model` recorded.
* Used by the resume-completion path to backfill model info, which only
@@ -0,0 +1,91 @@
import { isRecord } from '@n8n/utils/is-record';
import type { TimelineEvent } from '../execution-recorder';
export type AgentExecutionFailureKind = 'execution' | 'tool' | 'node' | 'workflow';
export interface AgentExecutionFailure {
kind: AgentExecutionFailureKind;
name: string | null;
message: string | null;
occurredAt: number;
}
export interface AgentExecutionFailureSummary {
count: number;
latest: AgentExecutionFailure;
}
export interface ThreadFailureSummary extends AgentExecutionFailureSummary {
latest: AgentExecutionFailure & { executionId: string };
}
const MAX_FAILURE_MESSAGE_LENGTH = 400;
function failureMessage(output: unknown): string | null {
if (!isRecord(output) || typeof output.error !== 'string') return null;
const message = output.error.trim();
return message ? message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) : null;
}
function isDeclinedToolOutput(output: unknown): boolean {
return isRecord(output) && output.declined === true;
}
function isSoftFailure(event: Extract<TimelineEvent, { type: 'tool-call' }>): boolean {
if (!isRecord(event.output)) return false;
return (
(event.kind === 'workflow' && event.output.status === 'error') ||
(event.name === 'delegate_subagent' && event.output.status === 'failed')
);
}
export function computeExecutionFailureSummary({
timeline,
status,
error,
stoppedAt,
}: {
timeline: TimelineEvent[];
status: 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
error: string | null;
stoppedAt: number;
}): AgentExecutionFailureSummary | null {
let count = 0;
let latest: AgentExecutionFailure | null = null;
const addFailure = (failure: AgentExecutionFailure) => {
count++;
if (latest === null || failure.occurredAt >= latest.occurredAt) latest = failure;
};
for (const event of timeline) {
if (
event.type !== 'tool-call' ||
event.endTime === 0 ||
isDeclinedToolOutput(event.output) ||
(event.success && !isSoftFailure(event))
) {
continue;
}
addFailure({
kind: event.kind,
name: event.nodeDisplayName ?? event.workflowName ?? event.name,
message: failureMessage(event.output),
occurredAt: event.endTime,
});
}
if (status === 'error' || status === 'interrupted') {
const message = error?.trim();
addFailure({
kind: 'execution',
name: null,
message: message ? message.slice(0, MAX_FAILURE_MESSAGE_LENGTH) : null,
occurredAt: stoppedAt,
});
}
return latest ? { count, latest } : null;
}
@@ -210,4 +210,37 @@ describe('AgentExecutionRepository', () => {
expect(result.has(thread.id)).toBe(false);
});
});
describe('failure summaries', () => {
it('aggregates counts and the latest failure per thread', async () => {
const thread = await createThread();
await createExecution({
threadId: thread.id,
failureSummary: {
count: 1,
latest: { kind: 'tool', name: 'Lookup', message: 'failed', occurredAt: 10 },
},
});
const latest = await createExecution({
threadId: thread.id,
failureSummary: {
count: 2,
latest: { kind: 'execution', name: null, message: 'stopped', occurredAt: 20 },
},
});
const result = await repository.findFailureSummariesByThreadIds([thread.id]);
expect(result.get(thread.id)).toEqual({
count: 3,
latest: {
kind: 'execution',
name: null,
message: 'stopped',
occurredAt: 20,
executionId: latest.id,
},
});
});
});
});
@@ -1470,9 +1470,12 @@
"agentSessions.showError.delete": "Problem deleting session",
"agentSessions.showError.load": "Problem loading sessions",
"agentSessions.status": "Status",
"agentSessions.status.cancelled": "Canceled",
"agentSessions.status.interrupted": "Interrupted",
"agentSessions.status.running": "Running",
"agentSessions.duration": "Duration",
"agentSessions.sessionId": "Session ID",
"agentSessions.success": "Success",
"agentSessions.success": "Succeeded",
"agentSessions.detail.selectMessage": "Click an assistant message to view details",
"agentSessions.detail.tokenUsage": "Token Usage",
"agentSessions.detail.input": "Input",
@@ -1524,6 +1527,11 @@
"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.executionFailed": "Execution failed",
"agentSessions.timeline.executionInterrupted": "Execution interrupted",
"agentSessions.timeline.executionFailedFallback": "The agent execution failed before completing.",
"agentSessions.timeline.executionInterruptedFallback": "The agent execution was interrupted before completing.",
"agentSessions.timeline.toolError": "Tool call failed",
"agentSessions.timeline.failed": "Failed",
"agentSessions.timeline.filter": "Filter",
@@ -84,6 +84,7 @@ const keyboardExecution = {
},
],
error: null,
failureSummary: null,
hitlStatus: null,
source: null,
} satisfies ThreadDetail['executions'][number];
@@ -195,7 +196,7 @@ describe('AgentSessionTimelinePanel', () => {
options
.filter((option) => option.presentation === 'badge')
.map(({ key, count }) => [key, count]),
).toEqual([['error', 1]]);
).toEqual([['error', 2]]);
});
it('omits status pills when the session has no matching statuses', async () => {
@@ -34,17 +34,28 @@ let documentRemoveEventListenerSpy: ReturnType<typeof vi.spyOn>;
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) =>
({
'agentSessions.viewTrace': 'View session trace',
'agentSessions.origin.preview': 'Preview',
'agentSessions.origin.instanceAi': 'AI Assistant',
'agentSessions.origin.mcp': 'MCP',
'agentSessions.origin.subAgent': 'Sub-agent',
'agentSessions.origin.schedule': 'Schedule',
'agentSessions.origin.workflow': 'Workflow',
'agentSessions.empty': 'No agent sessions',
})[key] ?? key,
baseText: (key: string, options?: { interpolate?: Record<string, string | number> }) => {
if (key === 'executionDetails.runningTimeFinished') {
return `in ${options?.interpolate?.time}`;
}
return (
{
'agentSessions.viewTrace': 'View session trace',
'agentSessions.origin.preview': 'Preview',
'agentSessions.origin.instanceAi': 'AI Assistant',
'agentSessions.origin.mcp': 'MCP',
'agentSessions.origin.subAgent': 'Sub-agent',
'agentSessions.origin.schedule': 'Schedule',
'agentSessions.origin.workflow': 'Workflow',
'agentSessions.empty': 'No agent sessions',
'agentSessions.success': 'Succeeded',
'agentSessions.status.cancelled': 'Canceled',
'agentSessions.status.interrupted': 'Interrupted',
'agentSessions.status.running': 'Running',
'agentSessions.timeline.error': 'Error',
}[key] ?? key
);
},
}),
}));
@@ -66,7 +77,10 @@ vi.mock('@n8n/design-system', () => ({
template: '<button v-bind="$attrs"><slot /></button>',
},
N8nTableBase: { template: '<table><slot /></table>' },
N8nTooltip: { template: '<div><slot /></div>' },
N8nText: {
props: ['color'],
template: '<span :data-color="color"><slot /></span>',
},
}));
vi.mock('../agentSessions.store', () => ({
@@ -145,6 +159,8 @@ function makeThread(overrides: Partial<AgentExecutionThread> = {}): AgentExecuti
createdAt: '2026-07-20T10:00:00.000Z',
updatedAt: '2026-07-20T10:05:00.000Z',
firstMessage: null,
failureSummary: null,
status: 'success',
...overrides,
};
}
@@ -205,7 +221,7 @@ describe('AgentSessionsListView', () => {
expect(traceButton.element.tagName).toBe('BUTTON');
expect(traceButton.attributes('type')).toBe('button');
expect(traceButton.text()).toBe('My session');
expect(wrapper.get('[data-test-id="agent-session-title"]').text()).toBe('My session');
routerPush.mockClear();
await traceButton.trigger('click');
@@ -213,6 +229,25 @@ describe('AgentSessionsListView', () => {
expect(routerPush).toHaveBeenCalledExactlyOnceWith(expectedRoute);
});
it.each([
['success', 'Succeeded', 'success', true],
['error', 'Error', 'danger', true],
['cancelled', 'Canceled', 'warning', true],
['interrupted', 'Interrupted', 'warning', true],
['running', 'Running', 'text-base', false],
] as const)('renders the %s session state', async (status, label, color, showsDuration) => {
const wrapper = await mountView({
threads: [makeThread({ status })],
});
const indicator = wrapper.get('[data-testid="agent-session-status-indicator"]');
expect(indicator.text()).toBe(label);
expect(indicator.attributes('data-color')).toBe(color);
expect(wrapper.find('[data-testid="agent-session-status-duration"]').exists()).toBe(
showsDuration,
);
});
it('opens the parent trace in the current tab by default', async () => {
const wrapper = await mountView({
threads: [makeThread({ parentAgentId: 'parent-agent-1', parentThreadId: 'parent-thread-1' })],
@@ -284,6 +284,21 @@ describe('SessionDetailPanel — HITL sequence', () => {
});
describe('SessionDetailPanel — other kinds', () => {
it('shows a fatal execution error in a danger callout', () => {
const w = mountIt({
kind: 'execution-error',
executionId: 'e1',
executionStatus: 'error',
timestamp: 100,
content: 'Model request failed',
});
expect(w.get('[data-testid="execution-error-callout"]').text()).toContain(
'Model request failed',
);
expect(w.get('[data-test-id="detail-execution-error-badge"]').text()).toBe('Error');
});
it('renders Input/Output JSON sections for generic tool calls', () => {
const w = mountIt({
kind: 'tool',
@@ -96,6 +96,23 @@ describe('SessionTimelineChart', () => {
expect(blocks[1].attributes('style')).not.toMatch(/opacity:\s*0\.15/);
});
it('renders a synthetic execution error as a danger block', () => {
const w = mountChart({
items: [
item({
kind: 'execution-error',
executionStatus: 'error',
content: 'Model request failed',
timestamp: 1000,
}),
],
});
const block = w.get('[data-test-id="timeline-block"]');
expect(block.attributes('data-error')).toBe('true');
expect(block.attributes('style')).toContain('var(--color--red-400)');
});
it('renders idle blobs interleaved with events in chronological order', () => {
const w = mountChart({ idleRanges: [{ start: 1500, end: 2000 }] });
expect(w.findAll('[data-test-id="timeline-idle"]')).toHaveLength(1);
@@ -127,7 +144,6 @@ describe('SessionTimelineChart', () => {
});
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', () => {
@@ -142,7 +158,6 @@ describe('SessionTimelineChart', () => {
});
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', () => {
@@ -157,7 +172,6 @@ describe('SessionTimelineChart', () => {
});
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', () => {
@@ -196,6 +196,25 @@ describe('SessionTimelineTable', () => {
expect(w.get('[data-test-id="timeline-tool-error-badge"]').text()).toBe('Error');
});
it('renders a fatal execution as an error row', () => {
const w = mountTable({
items: [
{
kind: 'execution-error',
executionId: 'e1',
executionStatus: 'error',
timestamp: 1000,
content: 'Model request failed',
},
],
selectedIndex: null,
visibleKinds: new Set<string>(),
});
expect(w.get('[data-test-id="timeline-row"]').text()).toContain('Model request failed');
expect(w.get('[data-test-id="timeline-execution-error-badge"]').text()).toBe('Error');
});
it.each([
['approved', 'Approved'],
['declined', 'Declined'],
@@ -88,6 +88,7 @@ describe('timeline status filters', () => {
const approved = item({ kind: 'hitl-response', hitlResponseStatus: 'approved' });
const declined = item({ kind: 'hitl-response', hitlResponseStatus: 'declined' });
const errored = item({ kind: 'tool', toolOutcome: 'error' });
const executionError = item({ kind: 'execution-error', executionStatus: 'error' });
const handledWorkflowError = item({
kind: 'workflow',
toolOutcome: 'success',
@@ -99,6 +100,7 @@ describe('timeline status filters', () => {
expect(itemStatusFilterKey(declined)).toBe('declined');
expect(itemStatusFilterKey(errored)).toBe('error');
expect(itemStatusFilterKey(handledWorkflowError)).toBe('error');
expect(itemStatusFilterKey(executionError)).toBe('error');
expect(
itemStatusFilterKey(item({ kind: 'hitl-response', hitlResponseStatus: 'responded' })),
).toBeUndefined();
@@ -112,6 +114,7 @@ describe('timeline status filters', () => {
expect(matchesTimelineFilters(errored, new Set(['error']))).toBe(true);
expect(matchesTimelineFilters(errored, new Set(['tool']))).toBe(true);
expect(matchesTimelineFilters(handledWorkflowError, new Set(['error']))).toBe(true);
expect(matchesTimelineFilters(executionError, new Set(['error']))).toBe(true);
});
});
@@ -226,6 +229,7 @@ function exec(overrides: Partial<AgentExecution> = {}): AgentExecution {
cost: null,
timeline: null,
error: null,
failureSummary: null,
hitlStatus: null,
source: null,
...overrides,
@@ -294,6 +298,36 @@ describe('flattenExecutionsToTimelineItems', () => {
expect(items).toHaveLength(0);
});
it.each([
['error', 'Model failed'],
['interrupted', 'Agent execution was interrupted'],
] as const)('adds a selectable synthetic item for an %s execution', (status, error) => {
const items = flattenExecutionsToTimelineItems([
exec({
status,
error,
stoppedAt: '2026-04-24T10:00:05Z',
}),
]);
expect(items).toEqual([
{
kind: 'execution-error',
executionId: 'e-1',
executionStatus: status,
content: error,
timestamp: Date.parse('2026-04-24T10:00:05Z'),
},
]);
});
it.each(['success', 'cancelled'] as const)(
'does not add a synthetic item for a %s execution',
(status) => {
expect(flattenExecutionsToTimelineItems([exec({ status })])).toEqual([]);
},
);
it('maps a generic HITL flow to tool call, request, and user response items', () => {
const items = flattenExecutionsToTimelineItems([
withTimeline(
@@ -101,6 +101,10 @@ function labelForKey(key: string): string {
return i18n.baseText('agentSessions.timeline.workflow');
case 'node':
return i18n.baseText('agentSessions.timeline.node');
case 'execution-error':
return i18n.baseText('agentSessions.timeline.executionFailed');
case 'execution-interrupted':
return i18n.baseText('agentSessions.timeline.executionInterrupted');
case 'suspension':
return i18n.baseText('agentSessions.timeline.hitlRequest');
case 'hitl-response':
@@ -135,7 +139,9 @@ const filterOptions = computed<FilterOption[]>(() => {
const kindCounts = new Map<EventKind, number>();
const statusCounts = new Map<TimelineStatusFilterKey, number>();
for (const item of items.value) {
kindCounts.set(item.kind, (kindCounts.get(item.kind) ?? 0) + 1);
if (item.kind !== 'execution-error') {
kindCounts.set(item.kind, (kindCounts.get(item.kind) ?? 0) + 1);
}
const statusKey = itemStatusFilterKey(item);
if (statusKey) {
statusCounts.set(statusKey, (statusCounts.get(statusKey) ?? 0) + 1);
@@ -24,6 +24,8 @@ import WorkflowExecutionLogViewer from './WorkflowExecutionLogViewer.vue';
import ToolIoView from './ToolIoView.vue';
import type { TimelineItem } from '../session-timeline.types';
import {
executionErrorLabel,
executionErrorMessage,
hitlTimelineName,
isErroredToolCallTimelineItem,
isSubAgentTimelineItem,
@@ -176,6 +178,7 @@ const headerTitle = computed((): string => {
if (item.kind === 'node') return item.nodeDisplayName ?? formatToolNameForDisplay(item.toolName);
if (item.kind === 'user') return i18n.baseText('agentSessions.timeline.user');
if (item.kind === 'agent') return i18n.baseText('agentSessions.timeline.agent');
if (item.kind === 'execution-error') return executionErrorLabel(item, i18n);
if (item.kind === 'suspension') {
return item.hitlRequestType === 'approval'
? hitlTimelineName(item, i18n)
@@ -195,6 +198,7 @@ const headerIcon = computed((): IconName => {
if (item.kind === 'node') return 'box';
if (item.kind === 'user') return 'user';
if (item.kind === 'agent') return 'bot';
if (item.kind === 'execution-error') return 'circle-x';
if (item.kind === 'hitl-response') return 'message-square';
return 'clock';
});
@@ -243,7 +247,9 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n
:data-test-id="
status.kind === 'hitl-response'
? 'detail-hitl-response-badge'
: 'detail-tool-error-badge'
: item.kind === 'execution-error'
? 'detail-execution-error-badge'
: 'detail-tool-error-badge'
"
>
{{ i18n.baseText(status.labelKey) }}
@@ -281,7 +287,13 @@ const workflowFormOutput = computed((): { formUrl: string; message: string } | n
</N8nCard>
<div :class="$style.output">
<template v-if="item.kind === 'suspension'">
<template v-if="item.kind === 'execution-error'">
<N8nCallout theme="danger" data-testid="execution-error-callout">
{{ executionErrorMessage(item, i18n) }}
</N8nCallout>
</template>
<template v-else-if="item.kind === 'suspension'">
<div data-test-id="hitl-request-details">
<div :class="$style.label">
{{ i18n.baseText('agentSessions.timeline.requestDetails') }}
@@ -7,9 +7,11 @@ import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import type { CSSProperties } from 'vue';
import type { IdleRange, TimelineItem } from '../session-timeline.types';
import {
executionErrorLabel,
executionErrorMessage,
formatDuration,
hitlTimelineName,
isErroredToolCallTimelineItem,
isErroredTimelineItem,
isSubAgentTimelineItem,
matchesTimelineFilters,
timelineItemStatus,
@@ -114,6 +116,8 @@ function popoverLabel(item: TimelineItem): string {
return i18n.baseText('agentSessions.timeline.workflow');
case 'node':
return i18n.baseText('agentSessions.timeline.node');
case 'execution-error':
return executionErrorLabel(item, i18n);
case 'suspension':
return i18n.baseText(
item.hitlRequestType === 'approval'
@@ -142,6 +146,8 @@ function popoverName(item: TimelineItem): string {
return item.workflowName ?? formatToolNameForDisplay(item.toolName);
case 'node':
return item.nodeDisplayName ?? formatToolNameForDisplay(item.toolName);
case 'execution-error':
return executionErrorMessage(item, i18n);
case 'suspension':
case 'hitl-response':
return hitlTimelineName(item, i18n);
@@ -351,7 +357,9 @@ onBeforeUnmount(() => {
:data-test-id="
activePopoverStatus.kind === 'hitl-response'
? 'timeline-popover-hitl-response-badge'
: 'timeline-popover-tool-error-badge'
: activePopover.segment.item.kind === 'execution-error'
? 'timeline-popover-execution-error-badge'
: 'timeline-popover-tool-error-badge'
"
>
{{ i18n.baseText(activePopoverStatus.labelKey) }}
@@ -384,13 +392,9 @@ onBeforeUnmount(() => {
type="button"
data-test-id="timeline-block"
:data-timeline-index="seg.index"
:data-error="isErroredToolCallTimelineItem(seg.item) ? 'true' : undefined"
:data-error="isErroredTimelineItem(seg.item) ? 'true' : undefined"
:aria-label="blockAriaLabel(seg.item)"
:class="[
$style.block,
props.selectedIndex === seg.index && $style.selected,
isErroredToolCallTimelineItem(seg.item) && $style.error,
]"
:class="[$style.block, props.selectedIndex === seg.index && $style.selected]"
:data-selected="props.selectedIndex === seg.index ? 'true' : undefined"
:style="eventStyle(seg.item)"
@mouseenter="showPopover(seg, $event)"
@@ -506,16 +510,6 @@ onBeforeUnmount(() => {
z-index: 2;
}
.error {
outline: var(--focus--border-width) solid var(--border-color--danger);
outline-offset: var(--spacing--5xs);
z-index: 1;
}
.selected.error {
z-index: 2;
}
/*
* Keep the shared hover card compact so the single-line row layout
* (pill · name · duration · time) doesn't wrap.
@@ -29,6 +29,8 @@ const icon = computed((): IconName => {
return 'workflow';
case 'node':
return 'box';
case 'execution-error':
return 'circle-x';
case 'suspension':
case 'idle':
return 'clock';
@@ -8,6 +8,8 @@ import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import { VIEWS } from '@/app/constants/navigation';
import type { TimelineItem } from '../session-timeline.types';
import {
executionErrorLabel,
executionErrorMessage,
hitlTimelineName,
isSubAgentTimelineItem,
timelineItemStatus,
@@ -56,6 +58,8 @@ const infoText = computed((): string => {
return it.workflowName ?? formatToolNameForDisplay(it.toolName);
case 'node':
return it.nodeDisplayName ?? formatToolNameForDisplay(it.toolName);
case 'execution-error':
return executionErrorMessage(it, i18n);
case 'suspension':
case 'hitl-response':
return hitlTimelineName(it, i18n);
@@ -89,6 +93,8 @@ const label = computed((): string => {
return i18n.baseText('agentSessions.timeline.workflow');
case 'node':
return i18n.baseText('agentSessions.timeline.node');
case 'execution-error':
return executionErrorLabel(props.item, i18n);
case 'suspension':
return i18n.baseText(
props.item.hitlRequestType === 'approval'
@@ -130,7 +136,9 @@ const label = computed((): string => {
:data-test-id="
status.kind === 'hitl-response'
? 'timeline-hitl-response-badge'
: 'timeline-tool-error-badge'
: item.kind === 'execution-error'
? 'timeline-execution-error-badge'
: 'timeline-tool-error-badge'
"
>
{{ i18n.baseText(status.labelKey) }}
@@ -38,6 +38,10 @@ function labelForKey(key: string): string {
return i18n.baseText('agentSessions.timeline.workflow');
case 'node':
return i18n.baseText('agentSessions.timeline.node');
case 'execution-error':
return i18n.baseText('agentSessions.timeline.executionFailed');
case 'execution-interrupted':
return i18n.baseText('agentSessions.timeline.executionInterrupted');
case 'suspension':
return i18n.baseText('agentSessions.timeline.hitlRequest');
case 'hitl-response':
@@ -23,10 +23,29 @@ export interface AgentExecutionThread {
firstMessage?: string | null;
/** Earliest non-null execution source for the thread (e.g. slack, telegram). */
source?: string | null;
failureSummary?: ThreadFailureSummary | null;
status?: AgentExecutionStatus | null;
}
export type AgentExecutionStatus = 'running' | 'success' | 'error' | 'cancelled' | 'interrupted';
export type AgentExecutionHitlStatus = 'suspended' | 'resumed';
export type AgentExecutionFailureKind = 'execution' | 'tool' | 'node' | 'workflow';
export interface AgentExecutionFailure {
kind: AgentExecutionFailureKind;
name: string | null;
message: string | null;
occurredAt: number;
}
export interface AgentExecutionFailureSummary {
count: number;
latest: AgentExecutionFailure;
}
export interface ThreadFailureSummary extends AgentExecutionFailureSummary {
latest: AgentExecutionFailure & { executionId: string };
}
/**
* Raw timeline event shape as persisted on the agent_execution row.
@@ -62,6 +81,7 @@ export interface AgentExecution {
cost: number | null;
timeline: AgentExecutionTimelineEvent[] | null;
error: string | null;
failureSummary: AgentExecutionFailureSummary | null;
hitlStatus: AgentExecutionHitlStatus | null;
source: string | null;
}
@@ -19,6 +19,11 @@ export function pillColors(
return { backgroundColor: 'var(--color--orange-200)', color: 'var(--color--orange-950)' };
case 'node':
return { backgroundColor: 'var(--color--neutral-200)', color: 'var(--color--neutral-950)' };
case 'execution-error':
return {
backgroundColor: 'var(--color--red-150)',
color: 'var(--text-color--danger)',
};
case 'suspension':
case 'idle':
return { backgroundColor: 'var(--color--yellow-200)', color: 'var(--color--yellow-950)' };
@@ -4,6 +4,7 @@ export type EventKind =
| 'tool'
| 'node'
| 'workflow'
| 'execution-error'
| 'suspension'
| 'hitl-response';
@@ -24,6 +25,7 @@ export interface TimelineItem {
toolCallId?: string;
toolInput?: unknown;
toolOutput?: unknown;
executionStatus?: 'error' | 'interrupted';
/** Terminal outcome of a tool execution. Human decisions are represented on HITL response items. */
toolOutcome?: ToolCallOutcome;
/** @deprecated Use `toolOutcome`. Kept for compatibility with existing timeline consumers. */
@@ -88,6 +88,10 @@ export function isErroredToolCallTimelineItem(item: TimelineItem): boolean {
);
}
export function isErroredTimelineItem(item: TimelineItem): boolean {
return item.kind === 'execution-error' || isErroredToolCallTimelineItem(item);
}
/** Extracts a human-readable error message from a failed item's tool output. */
export function timelineItemErrorMessage(item: TimelineItem): string {
if (!isErroredToolCallTimelineItem(item)) return '';
@@ -105,6 +109,23 @@ export function hitlTimelineNameKey(item: TimelineItem): BaseTextKey | undefined
type TimelineI18n = Pick<ReturnType<typeof useI18n>, 'baseText'>;
export function executionErrorLabel(item: TimelineItem, i18n: TimelineI18n): string {
return i18n.baseText(
item.executionStatus === 'interrupted'
? 'agentSessions.timeline.executionInterrupted'
: 'agentSessions.timeline.executionFailed',
);
}
export function executionErrorMessage(item: TimelineItem, i18n: TimelineI18n): string {
if (item.content) return item.content;
return i18n.baseText(
item.executionStatus === 'interrupted'
? 'agentSessions.timeline.executionInterruptedFallback'
: 'agentSessions.timeline.executionFailedFallback',
);
}
export function linkedToolDisplayName(item: TimelineItem, i18n: TimelineI18n): string {
return (
item.hitlToolDisplayName ??
@@ -144,7 +165,7 @@ export function timelineItemStatus(item: TimelineItem): TimelineItemStatus | und
theme: 'default',
};
}
if (isErroredToolCallTimelineItem(item)) {
if (isErroredTimelineItem(item)) {
return { kind: 'tool-error', labelKey: 'agentSessions.timeline.error', theme: 'danger' };
}
return undefined;
@@ -173,7 +194,7 @@ export function itemFilterKey(item: TimelineItem): string {
}
export function itemStatusFilterKey(item: TimelineItem): TimelineStatusFilterKey | undefined {
if (isErroredToolCallTimelineItem(item)) return 'error';
if (isErroredTimelineItem(item)) return 'error';
if (
item.kind === 'hitl-response' &&
(item.hitlResponseStatus === 'approved' || item.hitlResponseStatus === 'declined')
@@ -213,6 +234,9 @@ export function timelineItemSearchText(
const parts: Array<string | undefined> = [];
parts.push(labelForKey(itemFilterKey(item)));
if (item.kind === 'execution-error' && item.executionStatus === 'interrupted') {
parts.push(labelForKey('execution-interrupted'));
}
if (item.kind === 'suspension') {
parts.push(
labelForKey(item.hitlRequestType === 'approval' ? 'approval-requested' : 'hitl-requested'),
@@ -224,7 +248,7 @@ export function timelineItemSearchText(
if (item.hitlResponseStatus) {
parts.push(labelForKey(item.hitlResponseStatus));
}
if (isErroredToolCallTimelineItem(item)) {
if (isErroredTimelineItem(item)) {
parts.push(labelForKey('error'));
}
@@ -294,6 +318,7 @@ const COLOR_MAP: Record<EventKind, string> = {
tool: 'var(--color--success)',
node: 'var(--color--text)',
workflow: 'var(--color--primary)',
'execution-error': 'var(--color--danger)',
suspension: 'var(--color--warning)',
'hitl-response': 'var(--color--blue-400)',
};
@@ -308,6 +333,7 @@ const CHART_BLOCK_COLOR_MAP: Record<EventKind, string> = {
tool: 'var(--color--green-600)',
node: 'var(--color--neutral-600)',
workflow: 'var(--color--orange-600)',
'execution-error': 'var(--color--red-400)',
suspension: 'var(--color--yellow-600)',
'hitl-response': 'var(--color--blue-600)',
};
@@ -618,6 +644,16 @@ export function flattenExecutionsToTimelineItems(executions: AgentExecution[]):
items.push(hitlResponseItem(hitlContext, exec.id, event.response, event.timestamp ?? 0));
}
}
if (exec.status === 'error' || exec.status === 'interrupted') {
const terminalTimestamp = exec.stoppedAt ?? exec.startedAt ?? exec.createdAt;
items.push({
kind: 'execution-error',
executionId: exec.id,
executionStatus: exec.status,
content: exec.error ?? undefined,
timestamp: terminalTimestamp ? new Date(terminalTimestamp).getTime() : 0,
});
}
}
return items;
}
@@ -95,6 +95,9 @@ const triggerIcon = computed((): IconName => {
const triggerLabel = computed((): string => {
const source = triggerSource.value;
if (!source) return '';
if (source === 'chat' || source === 'n8n_chat') {
return i18n.baseText('agentSessions.origin.preview');
}
return source.charAt(0).toUpperCase() + source.slice(1);
});
@@ -6,12 +6,15 @@ import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter';
import { useAgentSessionsStore } from '@/features/agents/agentSessions.store';
import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants';
import { useThreadTitle } from '@/features/agents/utils/thread-title';
import type { AgentExecutionThread } from '@/features/agents/composables/useAgentThreadsApi';
import type {
AgentExecutionStatus,
AgentExecutionThread,
} from '@/features/agents/composables/useAgentThreadsApi';
import { useI18n } from '@n8n/i18n';
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { N8nActionDropdown, N8nButton, N8nIcon, N8nTableBase } from '@n8n/design-system';
import { N8nActionDropdown, N8nButton, N8nIcon, N8nTableBase, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem, IconName } from '@n8n/design-system';
import { ElSkeletonItem } from 'element-plus';
@@ -92,6 +95,28 @@ function formatDuration(ms: number): string {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`;
}
function statusColor(status: AgentExecutionStatus): 'success' | 'danger' | 'warning' | 'text-base' {
if (status === 'success') return 'success';
if (status === 'error') return 'danger';
if (status === 'running') return 'text-base';
return 'warning';
}
function statusLabel(status: AgentExecutionStatus): string {
switch (status) {
case 'running':
return i18n.baseText('agentSessions.status.running');
case 'success':
return i18n.baseText('agentSessions.success');
case 'error':
return i18n.baseText('agentSessions.timeline.error');
case 'cancelled':
return i18n.baseText('agentSessions.status.cancelled');
case 'interrupted':
return i18n.baseText('agentSessions.status.interrupted');
}
}
function originPresentation(thread: AgentExecutionThread): OriginPresentation {
const rawSource = thread.source?.trim();
const source = rawSource ? rawSource.toLowerCase() : undefined;
@@ -210,19 +235,42 @@ async function loadMore() {
<template>
<div :class="[$style.wrapper, { [$style.embedded]: props.embedded }]">
<div :class="$style.tableContainer">
<N8nTableBase>
<N8nTableBase :class="$style.sessionsTable">
<tbody>
<tr
v-for="thread in sessionsStore.threads"
:key="thread.id"
:class="$style.clickableRow"
:class="[$style.clickableRow, thread.status === 'error' && $style.errorRow]"
data-test-id="agent-session-list-item"
@click="onViewTrace({ agentId, threadId: thread.id })"
>
<td :class="$style.titleCell">
<button type="button" :class="$style.sessionOpen" data-test-id="agent-session-open">
<span :class="$style.sessionTitle" data-test-id="agent-session-title">
{{ threadTitleOf(thread) }}
<span :class="$style.sessionTitleRow">
<span :class="$style.sessionTitle" data-test-id="agent-session-title">
{{ threadTitleOf(thread) }}
</span>
<span v-if="thread.status" :class="$style.statusRow">
<N8nText
:color="statusColor(thread.status)"
size="small"
data-testid="agent-session-status-indicator"
>
{{ statusLabel(thread.status) }}
</N8nText>
<N8nText
v-if="thread.status !== 'running'"
color="text-base"
size="small"
data-testid="agent-session-status-duration"
>
{{
i18n.baseText('executionDetails.runningTimeFinished', {
interpolate: { time: formatDuration(thread.totalDuration) },
})
}}
</N8nText>
</span>
</span>
</button>
</td>
@@ -238,9 +286,6 @@ async function loadMore() {
<td :class="$style.tokenCell" data-test-id="agent-session-token-usage">
{{ (thread.totalPromptTokens + thread.totalCompletionTokens).toLocaleString() }}t
</td>
<td :class="$style.durationCell" data-test-id="agent-session-duration">
{{ formatDuration(thread.totalDuration) }}
</td>
<td :class="$style.actionCell" @click.stop>
<div :class="$style.actionGroup">
<N8nActionDropdown
@@ -253,8 +298,8 @@ async function loadMore() {
</td>
</tr>
<template v-if="sessionsStore.loading && !sessionsStore.threads.length">
<tr v-for="item in 5" :key="item">
<td v-for="col in 6" :key="col">
<tr v-for="item in 5" :key="item" :class="$style.skeletonRow">
<td v-for="col in 5" :key="col">
<ElSkeletonItem />
</td>
</tr>
@@ -263,7 +308,7 @@ async function loadMore() {
v-if="!sessionsStore.loading && !sessionsStore.threads.length"
:class="$style.lastRow"
>
<td :colspan="6" style="text-align: center; padding: var(--spacing--lg)">
<td :colspan="5" style="text-align: center; padding: var(--spacing--lg)">
<template v-if="!sessionsStore.threads.length && !sessionsStore.loading">
<span data-test-id="agent-sessions-empty">
{{ i18n.baseText('agentSessions.empty') }}
@@ -272,7 +317,7 @@ async function loadMore() {
</td>
</tr>
<tr :class="$style.lastRow" v-if="sessionsStore.nextCursor">
<td :colspan="6">
<td :colspan="5">
<N8nButton
icon="refresh-cw"
variant="ghost"
@@ -318,6 +363,30 @@ async function loadMore() {
scrollbar-color: var(--border-color) transparent;
}
.sessionsTable {
width: 100%;
height: auto;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-size--sm);
white-space: nowrap;
td {
height: var(--height--3xl);
padding: 0 var(--spacing--xs);
border-bottom: 0;
vertical-align: middle;
}
td:first-child {
padding-left: var(--spacing--sm);
}
td:last-child {
padding-right: var(--spacing--sm);
}
}
.titleCell {
width: 46%;
min-width: var(--spacing--3xl);
@@ -330,11 +399,26 @@ async function loadMore() {
overflow: hidden;
color: var(--text-color);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
font-weight: var(--font-weight--bold);
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionTitleRow {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--4xs);
min-width: 0;
}
.statusRow {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
flex: 0 0 auto;
}
.sessionOpen {
@include focus.focus-visible-ring-offset;
@@ -352,8 +436,7 @@ async function loadMore() {
.originCell,
.dateCell,
.tokenCell,
.durationCell {
.tokenCell {
width: 1%;
white-space: nowrap;
}
@@ -373,8 +456,7 @@ async function loadMore() {
}
.dateCell,
.tokenCell,
.durationCell {
.tokenCell {
color: var(--text-color--subtler);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
@@ -394,24 +476,43 @@ async function loadMore() {
gap: var(--spacing--4xs);
}
.clickableRow {
.sessionsTable .clickableRow {
background-color: var(--execution-card--color--background);
cursor: pointer;
td {
color: var(--text-color--subtler);
}
.titleCell {
border-left: var(--spacing--4xs) var(--border-style)
var(--execution-card--border-color--success);
}
.actionCell {
text-align: right;
}
&:hover {
background-color: var(--background--hover);
background-color: var(--execution-card--color--background--hover);
}
}
.lastRow {
.sessionsTable .errorRow {
.titleCell {
border-left-color: var(--execution-card--border-color--error);
}
}
.sessionsTable .skeletonRow {
background-color: var(--execution-card--color--background);
}
.sessionsTable .lastRow {
background-color: transparent;
td {
height: var(--height--2xl);
text-align: center;
}
@@ -420,7 +521,7 @@ async function loadMore() {
}
&:hover {
background-color: var(--background--surface) !important;
background-color: transparent;
}
}
</style>