diff --git a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts index 1377b857740..4ae50d2d9c5 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts @@ -470,6 +470,90 @@ describe('agent-run-reducer', () => { expect(state.toolCallsById['tc-skill'].renderHint).toBe('skill'); }); + it('tool-input-start announces a pending tool call before its args stream', () => { + const state = stateWithRun('run-1', 'root'); + reduceEvent(state, { + type: 'tool-input-start', + runId: 'run-1', + agentId: 'root', + responseId: 'resp-1', + payload: { toolCallId: 'tc-1', toolName: 'build-workflow' }, + }); + + const tc = state.toolCallsById['tc-1']; + expect(tc).toBeDefined(); + expect(tc.args).toEqual({}); + expect(tc.isLoading).toBe(true); + expect(tc.renderHint).toBe('builder'); + expect(state.agentsById['root'].timeline).toContainEqual({ + type: 'tool-call', + toolCallId: 'tc-1', + responseId: 'resp-1', + }); + }); + + it('tool-call fills args on an announced call without duplicating it', () => { + const state = stateWithRun('run-1', 'root'); + reduceEvent(state, { + type: 'tool-input-start', + runId: 'run-1', + agentId: 'root', + payload: { toolCallId: 'tc-1', toolName: 'build-workflow' }, + }); + reduceEvent(state, { + type: 'tool-call', + runId: 'run-1', + agentId: 'root', + payload: { toolCallId: 'tc-1', toolName: 'build-workflow', args: { filePath: 'a.ts' } }, + }); + + expect(state.toolCallsById['tc-1'].args).toEqual({ filePath: 'a.ts' }); + expect(state.agentsById['root'].toolCalls).toHaveLength(1); + expect(state.agentsById['root'].timeline.filter((e) => e.type === 'tool-call')).toHaveLength( + 1, + ); + }); + + it('duplicate tool-input-start events are idempotent', () => { + const state = stateWithRun('run-1', 'root'); + const event = { + type: 'tool-input-start', + runId: 'run-1', + agentId: 'root', + payload: { toolCallId: 'tc-1', toolName: 'some-tool' }, + } as const; + reduceEvent(state, event); + reduceEvent(state, event); + + expect(state.agentsById['root'].toolCalls).toHaveLength(1); + expect(state.agentsById['root'].timeline.filter((e) => e.type === 'tool-call')).toHaveLength( + 1, + ); + }); + + it('uses the event publish timestamp for tool timing when present', () => { + const state = stateWithRun('run-1', 'root'); + reduceEvent(state, { + type: 'tool-call', + runId: 'run-1', + agentId: 'root', + ts: Date.parse('2026-01-01T00:00:00Z'), + payload: { toolCallId: 'tc-1', toolName: 'some-tool', args: {} }, + }); + reduceEvent(state, { + type: 'tool-result', + runId: 'run-1', + agentId: 'root', + ts: Date.parse('2026-01-01T00:01:30Z'), + payload: { toolCallId: 'tc-1', result: {} }, + }); + + // Replayed events must reconstruct the original timing, not "now". + const tc = state.toolCallsById['tc-1']; + expect(tc.startedAt).toBe('2026-01-01T00:00:00.000Z'); + expect(tc.completedAt).toBe('2026-01-01T00:01:30.000Z'); + }); + it('tool-result resolves tool call', () => { const state = stateWithRun('run-1', 'root'); reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'some-tool')); diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts index 10dff1822f2..b40829ccb6c 100644 --- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts +++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts @@ -110,6 +110,13 @@ function ensureAgent(state: AgentRunState, agentId: string): InstanceAiAgentNode return state.agentsById[agentId]; } +/** When the event was published — falls back to "now" for live events and + * old persisted events that predate the `ts` envelope field. Replays must + * use the publish time or tool durations collapse to processing time. */ +function eventTimestamp(event: { ts?: number }): string { + return (event.ts !== undefined ? new Date(event.ts) : new Date()).toISOString(); +} + /** Append text to timeline — merges consecutive text entries within the same responseId. */ function appendTimelineText( timeline: InstanceAiTimelineEntry[], @@ -241,6 +248,32 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent break; } + case 'tool-input-start': { + // Announces a tool call whose arguments are still streaming — surfaces + // the pending call immediately; `tool-call` fills the args in later. + if (!isSafeObjectKey(event.payload.toolCallId)) break; + if (state.toolCallsById[event.payload.toolCallId]) break; + const agent = ensureAgent(state, event.agentId); + if (agent) { + const tc: InstanceAiToolCallState = { + toolCallId: event.payload.toolCallId, + toolName: event.payload.toolName, + args: {}, + isLoading: true, + renderHint: getRenderHint(event.payload.toolName), + startedAt: eventTimestamp(event), + }; + state.toolCallsById[event.payload.toolCallId] = tc; + agent.toolCalls.push(tc); + agent.timeline.push({ + type: 'tool-call', + toolCallId: event.payload.toolCallId, + ...(event.responseId ? { responseId: event.responseId } : {}), + }); + } + break; + } + case 'text-block': { // Coalesced segment from the durable log (replay path). When the last // timeline entry is this segment's streamed deltas (mid-block reconnect: @@ -304,6 +337,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent case 'tool-call': { if (!isSafeObjectKey(event.payload.toolCallId)) break; + // Announced by a preceding tool-input-start: fill in the streamed args + // on the existing entry instead of duplicating it. + const announced = state.toolCallsById[event.payload.toolCallId]; + if (announced) { + announced.args = event.payload.args; + break; + } const agent = ensureAgent(state, event.agentId); if (agent) { const tc: InstanceAiToolCallState = { @@ -312,7 +352,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent args: event.payload.args, isLoading: true, renderHint: getRenderHint(event.payload.toolName), - startedAt: new Date().toISOString(), + startedAt: eventTimestamp(event), }; state.toolCallsById[event.payload.toolCallId] = tc; agent.toolCalls.push(tc); @@ -331,7 +371,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent if (tc) { tc.result = event.payload.result; tc.isLoading = false; - tc.completedAt = new Date().toISOString(); + tc.completedAt = eventTimestamp(event); } break; } @@ -342,7 +382,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent if (tc) { tc.error = event.payload.error; tc.isLoading = false; - tc.completedAt = new Date().toISOString(); + tc.completedAt = eventTimestamp(event); } break; } @@ -355,7 +395,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent if (tc) { tc.error = event.payload.error; tc.isLoading = false; - tc.completedAt = new Date().toISOString(); + tc.completedAt = eventTimestamp(event); } break; } diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index a2979197926..da570a59a9e 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -104,6 +104,7 @@ export const instanceAiEventTypeSchema = z.enum([ 'reasoning-delta', 'text-block', 'reasoning-block', + 'tool-input-start', 'tool-call', 'tool-result', 'tool-error', @@ -263,6 +264,14 @@ export const toolCallPayloadSchema = z.object({ args: z.record(z.unknown()), }); +/** Emitted when a tool call's arguments BEGIN streaming — args arrive later + * via the `tool-call` event. Lets the UI surface the pending call while + * large arguments (e.g. generated workflow code) are still streaming. */ +export const toolInputStartPayloadSchema = z.object({ + toolCallId: z.string(), + toolName: z.string(), +}); + export const toolResultPayloadSchema = z.object({ toolCallId: z.string(), result: z.unknown(), @@ -734,6 +743,9 @@ const eventBase = { userId: z.string().optional(), /** Anthropic API response ID (msg_01...) — groups events from the same LLM response. */ responseId: z.string().optional(), + /** Epoch ms stamped once at publish — replays (SSE reconnect, snapshot + * rebuilds) use it to reconstruct real timing instead of "now". */ + ts: z.number().optional(), }; export const instanceAiEventSchema = z.discriminatedUnion('type', [ @@ -751,6 +763,11 @@ export const instanceAiEventSchema = z.discriminatedUnion('type', [ ...eventBase, payload: reasoningDeltaPayloadSchema, }), + z.object({ + type: z.literal('tool-input-start'), + ...eventBase, + payload: toolInputStartPayloadSchema, + }), // Coalesced full text/reasoning of one streamed segment, produced by the // durable event log (deltas are live-only and never persisted). On replay the // reducer REPLACES the segment's streamed deltas, so a client that reconnects @@ -805,6 +822,7 @@ export type InstanceAiAgentSpawnedEvent = Extract; export type InstanceAiTextDeltaEvent = Extract; export type InstanceAiReasoningDeltaEvent = Extract; +export type InstanceAiToolInputStartEvent = Extract; export type InstanceAiToolCallEvent = Extract; export type InstanceAiToolResultEvent = Extract; export type InstanceAiToolErrorEvent = Extract; diff --git a/packages/@n8n/instance-ai/docs/streaming-protocol.md b/packages/@n8n/instance-ai/docs/streaming-protocol.md index be47abceb0e..deb45d2bf9b 100644 --- a/packages/@n8n/instance-ai/docs/streaming-protocol.md +++ b/packages/@n8n/instance-ai/docs/streaming-protocol.md @@ -65,6 +65,7 @@ Every event follows this schema: type: string; // event type runId: string; // correlates all events in a single message → response cycle agentId: string; // agent this event is attributed to in the UI + ts?: number; // epoch ms stamped at publish — replays reconstruct real timing from it payload?: object; // event-specific data } ``` @@ -123,9 +124,32 @@ decision-making and supports faster iteration. reasoning tokens; when a model doesn't support it, no `reasoning-delta` events are sent. The frontend should handle the absence gracefully. +### `tool-input-start` + +A tool call's arguments have started streaming from the model. Sent before +`tool-call` — for tools with large arguments (e.g. `build-workflow` streaming +generated workflow code) this can precede the full `tool-call` event by a +long time, so the frontend can surface the pending call immediately. + +```json +{ + "type": "tool-input-start", + "runId": "run_abc123", + "agentId": "agent-001", + "payload": { + "toolCallId": "tc_abc123", + "toolName": "build-workflow" + } +} +``` + +The frontend adds a pending entry to the agent's `toolCalls` with empty `args` +and `isLoading: true`; the subsequent `tool-call` event fills in the args. + ### `tool-call` -An agent is invoking a tool. Sent before the tool executes. +An agent is invoking a tool. Sent when the tool's arguments are complete, +before the tool executes. ```json { diff --git a/packages/@n8n/instance-ai/evaluations/harness/in-memory-event-bus.ts b/packages/@n8n/instance-ai/evaluations/harness/in-memory-event-bus.ts index b9dd8a59c92..5f9cbc09973 100644 --- a/packages/@n8n/instance-ai/evaluations/harness/in-memory-event-bus.ts +++ b/packages/@n8n/instance-ai/evaluations/harness/in-memory-event-bus.ts @@ -9,6 +9,7 @@ export function createInMemoryEventBus(): InstanceAiEventBus { return { publish(threadId, event) { const list = storeByThread.get(threadId) ?? []; + if (event.ts === undefined) event = { ...event, ts: Date.now() }; const stored: StoredEvent = { id: list.length + 1, event }; list.push(stored); storeByThread.set(threadId, list); diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index 04ab71cec94..f9ed14175c0 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -99,6 +99,7 @@ const agentStreamChunkTypes = new Set([ 'finish', 'text-delta', 'reasoning-delta', + 'tool-input-start', 'tool-call', 'tool-result', 'error', @@ -458,6 +459,14 @@ export function mapAgentChunkToEvent( return { type: 'text-delta', ...base, payload: { text: chunk.delta } }; case 'reasoning-delta': return { type: 'reasoning-delta', ...base, payload: { text: chunk.delta } }; + case 'tool-input-start': + // Surfaces the pending tool call while its arguments stream — the + // full `tool-call` event follows once the args are complete. + return { + type: 'tool-input-start', + ...base, + payload: { toolCallId: chunk.toolCallId, toolName: chunk.toolName }, + }; case 'tool-call': return mapToolCallChunk(chunk, base); case 'tool-result': diff --git a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts index c06690eb5b8..9460c089745 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts @@ -267,7 +267,9 @@ describe('parseStoredMessages', () => { const result = parseStoredMessages(messages); expect(result[1].content).toBe('Hello'); - expect(result[1].agentTree?.timeline).toEqual([{ type: 'text', content: 'Hello' }]); + expect(result[1].agentTree?.timeline).toEqual([ + { type: 'text', content: 'Hello', responseId: 'msg-a' }, + ]); }); it('should parse reasoning from native parts', () => { @@ -295,8 +297,8 @@ describe('parseStoredMessages', () => { expect(result[1].content).toBe('Answer'); // Reasoning keeps its chronological slot in the timeline expect(result[1].agentTree?.timeline).toEqual([ - { type: 'reasoning', content: 'Reasoning part' }, - { type: 'text', content: 'Answer' }, + { type: 'reasoning', content: 'Reasoning part', responseId: 'msg-a' }, + { type: 'text', content: 'Answer', responseId: 'msg-a' }, ]); }); @@ -321,7 +323,102 @@ describe('parseStoredMessages', () => { expect(result[1].reasoning).toBe('Just reasoning'); expect(result[1].content).toBe(''); expect(result[1].agentTree?.timeline).toEqual([ - { type: 'reasoning', content: 'Just reasoning' }, + { type: 'reasoning', content: 'Just reasoning', responseId: 'msg-a' }, + ]); + }); + + it('should bracket reconstructed tool calls with adjacent message timestamps', () => { + // Snapshot-less reloads have no real per-call timestamps; the interval + // between stored rows approximates them so thinking blocks can still + // derive a "Thought for Xs" duration. + const messages: StoredAgentMessage[] = [ + { + id: 'msg-u', + role: 'user', + content: 'Build it', + createdAt: makeDate(), + }, + { + id: 'msg-a1', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-done', + toolName: 'search-nodes', + input: {}, + state: 'resolved', + output: {}, + }, + { + type: 'tool-call', + toolCallId: 'tc-pending', + toolName: 'build-workflow', + input: {}, + state: 'pending', + }, + ], + createdAt: makeDate(12_000), + }, + ]; + + const result = parseStoredMessages(messages); + + const toolCalls = result[1].agentTree?.toolCalls ?? []; + expect(toolCalls[0]).toMatchObject({ + toolCallId: 'tc-done', + startedAt: makeDate().toISOString(), + completedAt: makeDate(12_000).toISOString(), + }); + // An unresolved call gets no completedAt — it never finished. + expect(toolCalls[1].startedAt).toBe(makeDate().toISOString()); + expect(toolCalls[1].completedAt).toBeUndefined(); + }); + + it('should group each assistant row under its own synthetic responseId', () => { + // One stored assistant row = one LLM response. The synthetic per-row + // responseId lets the frontend fold narration (text followed by trace + // content in the same response) into thinking blocks after a reload + // where no snapshot survived. + const messages: StoredAgentMessage[] = [ + { + id: 'msg-u', + role: 'user', + content: 'Build it', + createdAt: makeDate(), + }, + { + id: 'msg-a1', + role: 'assistant', + content: [ + { type: 'text', text: 'Checking the nodes first.' }, + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'search-nodes', + input: {}, + state: 'resolved', + output: {}, + }, + ], + createdAt: makeDate(1), + }, + { + id: 'msg-a2', + role: 'assistant', + content: [{ type: 'text', text: 'Done — here is the workflow.' }], + createdAt: makeDate(2), + }, + ]; + + const result = parseStoredMessages(messages); + + expect(result[1].agentTree?.timeline).toEqual([ + { type: 'text', content: 'Checking the nodes first.', responseId: 'msg-a1' }, + { type: 'tool-call', toolCallId: 'tc-1', responseId: 'msg-a1' }, + ]); + expect(result[2].agentTree?.timeline).toEqual([ + { type: 'text', content: 'Done — here is the workflow.', responseId: 'msg-a2' }, ]); }); diff --git a/packages/cli/src/modules/instance-ai/event-bus/__tests__/in-process-event-bus.test.ts b/packages/cli/src/modules/instance-ai/event-bus/__tests__/in-process-event-bus.test.ts index 7645460d2ba..7a4de764bd6 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/__tests__/in-process-event-bus.test.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/__tests__/in-process-event-bus.test.ts @@ -427,7 +427,11 @@ describe('InProcessEventBus', () => { expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'relay-instance-ai-event', - payload: { threadId: 'thread-1', storedEvent: { id: 1, event } }, + payload: { + threadId: 'thread-1', + // publish() stamps the publish time onto the event + storedEvent: { id: 1, event: { ...event, ts: expect.any(Number) } }, + }, }); }); @@ -534,7 +538,8 @@ describe('InProcessEventBus', () => { bus.publish(threadId, event); const call = eventLog.publish.mock.calls.at(-1)!; expect(call[0]).toBe(threadId); - expect(call[1]).toBe(event); + // publish() stamps `ts` onto a copy before handing it to the log + expect(call[1]).toEqual({ ...event, ts: expect.any(Number) }); return call[2] as EmitFn; } diff --git a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts index 497e5b19f16..1dd26db8866 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts @@ -102,6 +102,13 @@ export class InProcessEventBus implements InstanceAiEventBus { * local SSE subscribers, and relayed to sibling mains with its id. */ publish(threadId: string, event: InstanceAiEvent): void { + // Stamp publish time once — replays (SSE reconnect, snapshot rebuilds) + // rely on it to reconstruct real timing instead of processing time. + // Before the durable-log branch on purpose: persisted events must carry + // it too. + if (event.ts === undefined) { + event = { ...event, ts: Date.now() }; + } if (this.durableLogEnabled) { this.eventLog.publish(threadId, event, (drained) => this.onDrained(threadId, drained)); return; diff --git a/packages/cli/src/modules/instance-ai/message-parser.ts b/packages/cli/src/modules/instance-ai/message-parser.ts index 24275bc084b..8f9b5f7748f 100644 --- a/packages/cli/src/modules/instance-ai/message-parser.ts +++ b/packages/cli/src/modules/instance-ai/message-parser.ts @@ -162,7 +162,24 @@ function extractToolInvocations(content: unknown): StoredToolInvocation[] { return []; } -function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCallState { +/** + * Coarse per-row timing for reconstructed tool calls: the interval from the + * previous stored message to this one brackets everything the response did. + * Real per-call timestamps only exist in run snapshots; this approximation + * lets a snapshot-less reload still show "Thought for Xs" (derived from + * min-start/max-end across a thinking block) instead of a bare fallback. + * Known coarseness: every call in a row shares the bracket, and an HITL pause + * between rows counts as thinking time. + */ +interface RowTiming { + startedAt: string; + completedAt: string; +} + +function buildToolCallState( + invocation: StoredToolInvocation, + timing?: RowTiming, +): InstanceAiToolCallState { const isCompleted = invocation.state === 'result'; return { toolCallId: invocation.toolCallId, @@ -172,6 +189,8 @@ function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCal error: isCompleted ? invocation.error : undefined, isLoading: !isCompleted, renderHint: getRenderHint(invocation.toolName), + ...(timing ? { startedAt: timing.startedAt } : {}), + ...(timing && isCompleted ? { completedAt: timing.completedAt } : {}), }; } @@ -179,23 +198,32 @@ function buildToolCallState(invocation: StoredToolInvocation): InstanceAiToolCal * Build a chronological timeline from native parts (preserves reasoning vs * tool-call vs text ordering). Falls back to a reasoning-first, * tool-calls-next heuristic when parts aren't available. + * + * `responseId` is a synthetic per-message id: each stored assistant row is one + * LLM response, and the frontend needs response grouping to tell intermediate + * narration (trace content follows in the same response) from final answers. + * Without it, a reconstructed timeline renders every narration text outside + * the thinking blocks, splitting them. */ function buildTimeline( textContent: string, reasoning: string, toolCalls: InstanceAiToolCallState[], parts?: StoredContentPart[], + responseId?: string, ): InstanceAiTimelineEntry[] { + const responseRef = responseId ? { responseId } : {}; + // If parts are available, use their ordering (chronologically accurate) if (parts?.length) { const timeline: InstanceAiTimelineEntry[] = []; for (const part of parts) { if (part.type === 'text' && part.text) { - timeline.push({ type: 'text', content: part.text }); + timeline.push({ type: 'text', content: part.text, ...responseRef }); } else if (part.type === 'reasoning' && part.text) { - timeline.push({ type: 'reasoning', content: part.text }); + timeline.push({ type: 'reasoning', content: part.text, ...responseRef }); } else if (part.type === 'tool-call' && part.toolCallId) { - timeline.push({ type: 'tool-call', toolCallId: part.toolCallId }); + timeline.push({ type: 'tool-call', toolCallId: part.toolCallId, ...responseRef }); } } return timeline; @@ -205,13 +233,13 @@ function buildTimeline( // (most common agent pattern) const timeline: InstanceAiTimelineEntry[] = []; if (reasoning) { - timeline.push({ type: 'reasoning', content: reasoning }); + timeline.push({ type: 'reasoning', content: reasoning, ...responseRef }); } for (const tc of toolCalls) { - timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId }); + timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId, ...responseRef }); } if (textContent) { - timeline.push({ type: 'text', content: textContent }); + timeline.push({ type: 'text', content: textContent, ...responseRef }); } return timeline; } @@ -233,6 +261,7 @@ function buildFlatAgentTree( toolCalls: InstanceAiToolCallState[], parts?: StoredContentPart[], status: InstanceAiAgentNode['status'] = 'completed', + responseId?: string, ): InstanceAiAgentNode { const resolvedStatus = status === 'active' ? 'completed' : status; const settledToolCalls = @@ -247,7 +276,7 @@ function buildFlatAgentTree( reasoning, toolCalls: settledToolCalls, children: [], - timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts), + timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts, responseId), }; } @@ -444,7 +473,14 @@ export function parseStoredMessages( if (msg.role === 'assistant') { const reasoning = extractReasoningFromContent(msg.content); const invocations = extractToolInvocations(msg.content); - const toolCalls = invocations.map(buildToolCallState); + const prevMessage = messageIndex > 0 ? conversationMessages[messageIndex - 1] : undefined; + const timing: RowTiming | undefined = prevMessage + ? { + startedAt: prevMessage.createdAt.toISOString(), + completedAt: msg.createdAt.toISOString(), + } + : undefined; + const toolCalls = invocations.map((invocation) => buildToolCallState(invocation, timing)); const parts = extractParts(msg.content); const snapshot = takeSnapshotForAssistant(msg, messageIndex); @@ -452,9 +488,21 @@ export function parseStoredMessages( // Use the native runId from the snapshot (matches SSE events), // falling back to the user-message ID if no snapshot exists. const runId = snapshot?.runId ?? lastUserMessageId ?? msg.id; + // The message id doubles as the synthetic responseId: one stored + // assistant row = one LLM response, and unlike `runId` (which falls + // back to the user-message id shared by the whole turn) it is unique + // per row, so response grouping survives the flat-tree merge. const messageFlatTree = toolCalls.length > 0 || text || reasoning - ? buildFlatAgentTree(runId, text, reasoning, toolCalls, parts, snapshot?.tree.status) + ? buildFlatAgentTree( + runId, + text, + reasoning, + toolCalls, + parts, + snapshot?.tree.status, + msg.id, + ) : undefined; // Carry the cancellation cause onto the fallback tree so a stopped run is // still attributable (user/timeout/shutdown) after the snapshot was lost. diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStepGroup/AiActivityStepGroup.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStepGroup/AiActivityStepGroup.stories.ts index 5be1a385d5a..3bdd8a77407 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStepGroup/AiActivityStepGroup.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStepGroup/AiActivityStepGroup.stories.ts @@ -1,7 +1,6 @@ import type { StoryFn } from '@storybook/vue3-vite'; import { defineComponent } from 'vue'; -import ReasoningBlock from '../../../../../editor-ui/src/features/ai/instanceAi/components/ReasoningBlock.vue'; import N8nAiActivityStep from '../N8nAiActivityStep'; import N8nAiActivityStepButton from '../N8nAiActivityStepButton'; import N8nAiActivityStepChevron from '../N8nAiActivityStepChevron'; @@ -134,7 +133,6 @@ const storyComponents = { N8nAiActivityStepResultSection, ToolResultJson: StoryToolResultJson, ToolResultRenderer: StoryToolResultRenderer, - ReasoningBlock, }; const slotStyles = ''; @@ -170,15 +168,17 @@ export const Group: StoryFn = () => ({ const Template: StoryFn = (args) => ({ components: { N8nAiActivityStep, ...storyComponents }, - setup: () => ({ args }), + setup: () => ({ args, jsonTextStyle }), template: `
- + :label="args.label" + :loading="args.loading" + > +
{{ args.result.content }}
+ {{ content }}', props: ['content'] }, + ToolResultJson: true, + ToolResultRenderer: true, + }, + }, +}); + +function makeToolCall(overrides: Partial = {}): InstanceAiToolCallState { + return { + toolCallId: 'tc-1', + toolName: 'search-nodes', + args: {}, + isLoading: false, + ...overrides, + }; +} + +function makeAgentNode(overrides: Partial = {}): InstanceAiAgentNode { + return { + agentId: 'agent-1', + role: 'orchestrator', + status: 'completed', + textContent: '', + reasoning: '', + toolCalls: [], + children: [], + timeline: [], + ...overrides, + }; +} + +const reasoning = (content: string): InstanceAiTimelineEntry => ({ type: 'reasoning', content }); +const text = (content: string): InstanceAiTimelineEntry => ({ type: 'text', content }); +const toolEntry = (toolCallId: string): InstanceAiTimelineEntry => ({ + type: 'tool-call', + toolCallId, +}); + +describe('ThinkingBlock', () => { + beforeEach(() => { + createTestingPinia({ stubActions: false }); + }); + + it('should show duration from tool-call timestamps when settled', () => { + const tc = makeToolCall({ + startedAt: '2026-01-01T00:00:00Z', + completedAt: '2026-01-01T00:00:12Z', + }); + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ toolCalls: [tc] }), + entries: [reasoning('hmm'), toolEntry('tc-1')], + active: false, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent('Thought for 12s'); + }); + + it('should format durations over a minute', () => { + const tc = makeToolCall({ + startedAt: '2026-01-01T00:00:00Z', + completedAt: '2026-01-01T00:01:05Z', + }); + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ toolCalls: [tc] }), + entries: [toolEntry('tc-1')], + active: false, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent('Thought for 1m 5s'); + }); + + it('should fall back to a static title when no timestamps exist', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode(), + entries: [reasoning('only reasoning, no tools')], + active: false, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent('Finished thinking'); + }); + + it('should be collapsed by default when settled', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode(), + entries: [reasoning('hmm')], + active: false, + }, + }); + + expect(getByTestId('thinking-block-header').getAttribute('aria-expanded')).toBe('false'); + }); + + it('should stay collapsed while active and stream the latest segment first sentence', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [ + reasoning('Reasoning start. More reasoning.'), + text('Checking the schema first. Then building.'), + ], + active: true, + }, + }); + + const header = getByTestId('thinking-block-header'); + expect(header.getAttribute('aria-expanded')).toBe('false'); + expect(header).toHaveTextContent('Checking the schema first.'); + }); + + it('should keep the previous status line while a new segment is still blank', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [ + text('Checking the schema first. Then building.'), + reasoning('\n\n'), // reasoning streams often open with whitespace + ], + active: true, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent('Checking the schema first.'); + }); + + it('should replace the status line when a new trace segment starts', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [ + text('Checking the schema first. Then building.'), + reasoning('The classifier needs rewiring. Fixing it.'), + ], + active: true, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent( + 'The classifier needs rewiring.', + ); + }); + + it('should show the reasoning first sentence before any narration exists', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('The user wants a Gmail workflow. Let me check the nodes.')], + active: true, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent( + 'The user wants a Gmail workflow.', + ); + }); + + it('should keep the status line unchanged when expanded mid-stream', async () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('Some reasoning. More.')], + active: true, + }, + }); + + const header = getByTestId('thinking-block-header'); + await userEvent.click(header); + + expect(header.getAttribute('aria-expanded')).toBe('true'); + expect(header).toHaveTextContent('Some reasoning.'); + }); + + it('should title the block "Waiting for your input" while paused on a confirmation', () => { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('Some reasoning. More.')], + active: true, + awaitingInput: true, + }, + }); + + expect(getByTestId('thinking-block-header')).toHaveTextContent('Waiting for your input'); + }); + + it('should show the latest tool call on the subline while it is the tail entry', () => { + const tc = makeToolCall({ toolCallId: 'tc-1', toolName: 'search-nodes', isLoading: true }); + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }), + entries: [reasoning('Looking for nodes. Now searching.'), toolEntry('tc-1')], + active: true, + }, + }); + + expect(getByTestId('thinking-block-subline')).toBeInTheDocument(); + expect(getByTestId('thinking-block-subline')).not.toHaveTextContent('Thinking'); + }); + + it('should show "Thinking" on the subline when no tool call is the tail', () => { + // Covers the reasoning-only phases (run start, between tool calls) — + // the block must always carry a live signal while active. + const tc = makeToolCall({ toolCallId: 'tc-1' }); + const noTools = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('Figuring out the trigger. Hmm.')], + active: true, + }, + }); + expect(noTools.getByTestId('thinking-block-subline')).toHaveTextContent('Thinking'); + noTools.unmount(); + + const toolThenReasoning = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }), + entries: [toolEntry('tc-1'), reasoning('Got the results. Next step.')], + active: true, + }, + }); + expect(toolThenReasoning.getByTestId('thinking-block-subline')).toHaveTextContent('Thinking'); + }); + + it('should show the elapsed in the header while expanded', async () => { + vi.useFakeTimers(); + try { + const { getByTestId } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('Figuring out the trigger. Hmm.')], + active: true, + }, + }); + + await vi.advanceTimersByTimeAsync(5000); + const header = getByTestId('thinking-block-header'); + await fireEvent.click(header); + + // The subline is hidden while expanded — the timer moves to the header. + expect(header).toHaveTextContent('5s'); + } finally { + vi.useRealTimers(); + } + }); + + it('should reset the subline timer after an HITL pause', async () => { + vi.useFakeTimers(); + try { + const { getByTestId, rerender } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active' }), + entries: [reasoning('Figuring out the trigger. Hmm.')], + active: true, + }, + }); + + await vi.advanceTimersByTimeAsync(5000); + expect(getByTestId('thinking-block-subline')).toHaveTextContent('5s'); + + // Waiting for the user isn't thinking time — resume restarts at zero. + await rerender({ awaitingInput: true }); + await rerender({ awaitingInput: false }); + await vi.advanceTimersByTimeAsync(1000); + + expect(getByTestId('thinking-block-subline')).toHaveTextContent('1s'); + expect(getByTestId('thinking-block-subline')).not.toHaveTextContent('6s'); + } finally { + vi.useRealTimers(); + } + }); + + it('should hide the subline when the block settles or pauses for input', () => { + const tc = makeToolCall({ toolCallId: 'tc-1' }); + const settled = renderComponent({ + props: { + agentNode: makeAgentNode({ toolCalls: [tc] }), + entries: [toolEntry('tc-1')], + active: false, + }, + }); + expect(settled.queryByTestId('thinking-block-subline')).not.toBeInTheDocument(); + settled.unmount(); + + const paused = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }), + entries: [toolEntry('tc-1')], + active: true, + awaitingInput: true, + }, + }); + expect(paused.queryByTestId('thinking-block-subline')).not.toBeInTheDocument(); + }); + + it('should collapse a user-expanded block and settle the title when streaming ends', async () => { + const tc = makeToolCall({ + startedAt: '2026-01-01T00:00:00Z', + completedAt: '2026-01-01T00:00:03Z', + }); + const { getByTestId, rerender } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }), + entries: [reasoning('hmm'), toolEntry('tc-1')], + active: true, + }, + }); + + await userEvent.click(getByTestId('thinking-block-header')); + await rerender({ active: false }); + + const header = getByTestId('thinking-block-header'); + expect(header.getAttribute('aria-expanded')).toBe('false'); + expect(header).toHaveTextContent('Thought for 3s'); + }); + + it('should render narration, reasoning, and tool rows inside the rail when expanded', async () => { + const tc = makeToolCall({ toolCallId: 'tc-1', toolName: 'credentials' }); + const { getByTestId, getAllByText, getByText } = renderComponent({ + props: { + agentNode: makeAgentNode({ status: 'active', toolCalls: [tc] }), + entries: [reasoning('deep thoughts'), text('Checking credentials now.'), toolEntry('tc-1')], + active: true, + }, + }); + + await userEvent.click(getByTestId('thinking-block-header')); + + // Appears twice: as the header status line and as the narration paragraph + expect(getAllByText('Checking credentials now.')).toHaveLength(2); + // Reasoning rows are labeled with their own first sentence + expect(getByText('deep thoughts')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts index 7a5cd01107d..367be8eb8d1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/agentTimeline.utils.test.ts @@ -1,9 +1,13 @@ import { describe, test, expect } from 'vitest'; -import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types'; +import type { + InstanceAiAgentNode, + InstanceAiTimelineEntry, + InstanceAiToolCallState, +} from '@n8n/api-types'; import { + buildTimelineBlocks, extractArtifacts, isStreamingTimelineEntry, - isVisibleTimelineEntry, } from '../agentTimeline.utils'; function makeToolCall(overrides: Partial): InstanceAiToolCallState { @@ -263,109 +267,260 @@ describe('extractArtifacts', () => { }); }); -describe('isVisibleTimelineEntry', () => { - const toolCallEntry = { type: 'tool-call' as const, toolCallId: 'tc-1' }; +describe('buildTimelineBlocks', () => { + const reasoning = (responseId?: string): InstanceAiTimelineEntry => ({ + type: 'reasoning', + content: 'thinking...', + responseId, + }); + const text = (content: string, responseId?: string): InstanceAiTimelineEntry => ({ + type: 'text', + content, + responseId, + }); + const toolEntry = (toolCallId: string, responseId?: string): InstanceAiTimelineEntry => ({ + type: 'tool-call', + toolCallId, + responseId, + }); - function visibilityOf(tc: InstanceAiToolCallState): boolean { - return isVisibleTimelineEntry(toolCallEntry, { [tc.toolCallId]: tc }, {}); + function blocksOf( + entries: InstanceAiTimelineEntry[], + toolCalls: InstanceAiToolCallState[] = [], + status: InstanceAiAgentNode['status'] = 'completed', + children: InstanceAiAgentNode[] = [], + ) { + const toolCallsById = Object.fromEntries(toolCalls.map((tc) => [tc.toolCallId, tc])); + const childrenById = Object.fromEntries(children.map((c) => [c.agentId, c])); + return buildTimelineBlocks(entries, toolCallsById, childrenById, status); } - test('text entries are always visible', () => { - expect(isVisibleTimelineEntry({ type: 'text', content: 'hi' }, {}, {})).toBe(true); + test('merges consecutive reasoning and generic tool calls into one thinking block', () => { + const blocks = blocksOf( + [reasoning('r1'), toolEntry('tc-1', 'r1'), toolEntry('tc-2', 'r1'), reasoning('r2')], + [makeToolCall({ toolCallId: 'tc-1' }), makeToolCall({ toolCallId: 'tc-2' })], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe('thinking'); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(4); }); - test('reasoning entries are always visible', () => { - expect(isVisibleTimelineEntry({ type: 'reasoning', content: 'hmm' }, {}, {})).toBe(true); + test('text followed by same-response trace content joins the thinking block', () => { + const blocks = blocksOf( + [reasoning('r1'), text('Let me check the schema.', 'r1'), toolEntry('tc-1', 'r1')], + [makeToolCall({ toolCallId: 'tc-1' })], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(3); }); - test('tool-call entries without a matching tool call are hidden', () => { - expect(isVisibleTimelineEntry(toolCallEntry, {}, {})).toBe(false); + test('trailing text of a response is user-facing and splits blocks', () => { + const blocks = blocksOf( + [reasoning('r1'), toolEntry('tc-1', 'r1'), text('Here is your answer.', 'r1')], + [makeToolCall({ toolCallId: 'tc-1' })], + ); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']); }); - test('internal bookkeeping tools are hidden', () => { - expect(visibilityOf(makeToolCall({ toolName: 'updateWorkingMemory' }))).toBe(false); + test('short streaming tail text after same-response trace stays inside the block', () => { + const blocks = blocksOf( + [reasoning('r1'), text('Now building the workflow.', 'r1')], + [], + 'active', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2); }); - test('builder/data-table/eval-setup hints are hidden (represented by artifact cards)', () => { - expect(visibilityOf(makeToolCall({ renderHint: 'builder' }))).toBe(false); - expect(visibilityOf(makeToolCall({ renderHint: 'data-table' }))).toBe(false); - expect(visibilityOf(makeToolCall({ renderHint: 'eval-setup' }))).toBe(false); + test('answer-length streaming tail text promotes out of the block', () => { + const longText = 'This is the final answer. '.repeat(10); // > 200 chars + const blocks = blocksOf([reasoning('r1'), text(longText, 'r1')], [], 'active'); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']); }); - test('builder hint stays hidden even with a plan-review confirmation (template order)', () => { - expect( - visibilityOf( + test('streaming tail text without same-response trace renders outside', () => { + const blocks = blocksOf([reasoning('r1'), text('Quick answer.', 'r2')], [], 'active'); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']); + }); + + test('short trailing text promotes out once the run settles', () => { + const entries = [reasoning('r1'), text('Done, workflow created.', 'r1')]; + + const streaming = blocksOf(entries, [], 'active'); + expect(streaming).toHaveLength(1); + + const settled = blocksOf(entries, [], 'completed'); + expect(settled.map((b) => b.type)).toEqual(['thinking', 'text']); + }); + + test('text without responseId is always user-facing (old snapshots)', () => { + const blocks = blocksOf( + [reasoning(), text('narration'), toolEntry('tc-1')], + [makeToolCall({ toolCallId: 'tc-1' })], + ); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text', 'thinking']); + }); + + test('hidden tool calls are dropped without splitting a thinking run', () => { + const blocks = blocksOf( + [ + toolEntry('tc-1', 'r1'), + toolEntry('tc-hidden', 'r1'), + toolEntry('tc-builder', 'r1'), + toolEntry('tc-2', 'r1'), + ], + [ + makeToolCall({ toolCallId: 'tc-1' }), + makeToolCall({ toolCallId: 'tc-hidden', toolName: 'updateWorkingMemory' }), makeToolCall({ + toolCallId: 'tc-builder', + toolName: 'build-workflow-with-agent', renderHint: 'builder', - confirmation: { - requestId: 'r1', - severity: 'info', - message: 'Review plan', - inputType: 'plan-review', - }, }), - ), - ).toBe(false); + makeToolCall({ toolCallId: 'tc-2' }), + ], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2); }); - test('plan-review confirmations render a panel', () => { - expect( - visibilityOf( + test('in-thread build-workflow renders as a trace row; agent-delegated builds stay hidden', () => { + const blocks = blocksOf( + [toolEntry('tc-build', 'r1'), toolEntry('tc-delegated', 'r1')], + [ + makeToolCall({ toolCallId: 'tc-build', toolName: 'build-workflow', renderHint: 'builder' }), makeToolCall({ - renderHint: 'planner', - confirmation: { - requestId: 'r1', - severity: 'info', - message: 'Review plan', - inputType: 'plan-review', - }, + toolCallId: 'tc-delegated', + toolName: 'build-workflow-with-agent', + renderHint: 'builder', }), - ), - ).toBe(true); + ], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toEqual([ + expect.objectContaining({ toolCallId: 'tc-build' }), + ]); }); - test('planner hint without a plan review renders nothing', () => { - expect(visibilityOf(makeToolCall({ renderHint: 'planner' }))).toBe(false); - }); - - test('question forms are suppressed while pending, visible once answered', () => { - const questions = (isLoading: boolean) => - makeToolCall({ - isLoading, - confirmation: { - requestId: 'r1', - severity: 'info', - message: 'Answer questions', - inputType: 'questions', - }, - }); - expect(visibilityOf(questions(true))).toBe(false); - expect(visibilityOf(questions(false))).toBe(true); - }); - - test('tasks, default, and generic tool calls are visible', () => { - expect(visibilityOf(makeToolCall({ renderHint: 'tasks' }))).toBe(true); - expect(visibilityOf(makeToolCall({ renderHint: 'default' }))).toBe(true); - expect(visibilityOf(makeToolCall({ renderHint: 'skill' }))).toBe(true); - expect(visibilityOf(makeToolCall({}))).toBe(true); - }); - - test('child entries are visible unless the child is a hoisted active builder', () => { - const entry = { type: 'child' as const, agentId: 'sub-1' }; - const completedBuilder = makeAgentNode({ - agentId: 'sub-1', - role: 'workflow-builder', - status: 'completed', + test('user-facing tool calls split thinking runs', () => { + const answeredQuestions = makeToolCall({ + toolCallId: 'tc-q', + isLoading: false, + confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' }, }); + const blocks = blocksOf( + [toolEntry('tc-1', 'r1'), toolEntry('tc-q', 'r1'), toolEntry('tc-2', 'r2')], + [ + makeToolCall({ toolCallId: 'tc-1' }), + answeredQuestions, + makeToolCall({ toolCallId: 'tc-2' }), + ], + ); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'questions', 'thinking']); + }); + + test('pending question forms are dropped without splitting', () => { + const pendingQuestions = makeToolCall({ + toolCallId: 'tc-q', + isLoading: true, + confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' }, + }); + const blocks = blocksOf( + [toolEntry('tc-1', 'r1'), toolEntry('tc-q', 'r1'), toolEntry('tc-2', 'r1')], + [ + makeToolCall({ toolCallId: 'tc-1' }), + pendingQuestions, + makeToolCall({ toolCallId: 'tc-2' }), + ], + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(2); + }); + + test('child agents split thinking runs; hoisted active builders are dropped', () => { + const completedChild = makeAgentNode({ agentId: 'sub-1' }); const activeBuilder = makeAgentNode({ - agentId: 'sub-1', + agentId: 'sub-2', role: 'workflow-builder', status: 'active', }); + const childEntry = (agentId: string): InstanceAiTimelineEntry => ({ type: 'child', agentId }); - expect(isVisibleTimelineEntry(entry, {}, { 'sub-1': completedBuilder })).toBe(true); - expect(isVisibleTimelineEntry(entry, {}, { 'sub-1': activeBuilder })).toBe(false); - expect(isVisibleTimelineEntry(entry, {}, {})).toBe(false); + const split = blocksOf( + [reasoning('r1'), childEntry('sub-1'), reasoning('r2')], + [], + 'completed', + [completedChild], + ); + expect(split.map((b) => b.type)).toEqual(['thinking', 'child', 'thinking']); + + const merged = blocksOf( + [reasoning('r1'), childEntry('sub-2'), reasoning('r2')], + [], + 'completed', + [activeBuilder], + ); + expect(merged).toHaveLength(1); + }); + + test('flags only the trailing thinking block as active while the agent streams', () => { + const streaming = blocksOf( + [reasoning('r1'), text('Answer.', 'r1'), reasoning('r2')], + [], + 'active', + ); + expect(streaming.map((b) => b.type)).toEqual(['thinking', 'text', 'thinking']); + expect(streaming[0].type === 'thinking' && streaming[0].active).toBe(false); + expect(streaming[2].type === 'thinking' && streaming[2].active).toBe(true); + }); + + test('the trailing thinking block stays active while tentative tail text streams', () => { + // Tail text may still fold back into the block (if same-response trace + // content follows), so the block must not settle to "Thought for Xs" yet. + const tailText = blocksOf([reasoning('r1'), text('Answer...', 'r2')], [], 'active'); + expect(tailText.map((b) => b.type)).toEqual(['thinking', 'text']); + expect(tailText[0].type === 'thinking' && tailText[0].active).toBe(true); + }); + + test('trailing text past the narration cap settles the thinking block', () => { + // Answer-length text is a committed answer — a block still "thinking" + // behind a streaming answer reads as lag. + const longAnswer = 'A'.repeat(240) + '.'; + const blocks = blocksOf([reasoning('r1'), text(longAnswer, 'r2')], [], 'active'); + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'text']); + expect(blocks[0].type === 'thinking' && blocks[0].active).toBe(false); + }); + + test('real user-facing interruptions settle the thinking block immediately', () => { + const answeredQuestions = makeToolCall({ + toolCallId: 'tc-q', + isLoading: false, + confirmation: { requestId: 'r1', severity: 'info', message: 'q', inputType: 'questions' }, + }); + const blocks = blocksOf( + [reasoning('r1'), toolEntry('tc-q', 'r1')], + [answeredQuestions], + 'active', + ); + + expect(blocks.map((b) => b.type)).toEqual(['thinking', 'questions']); + expect(blocks[0].type === 'thinking' && blocks[0].active).toBe(false); + }); + + test('no block is active once the agent has settled', () => { + const completed = blocksOf([reasoning('r1')], [], 'completed'); + expect(completed[0].type === 'thinking' && completed[0].active).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/toolLabels.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/toolLabels.test.ts index bff3798c123..3d2b7eebae6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/toolLabels.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/toolLabels.test.ts @@ -54,7 +54,7 @@ describe('getToolIcon', () => { }); test('returns default icon for removed delegate tool', () => { - expect(getToolIcon('delegate')).toBe('settings'); + expect(getToolIcon('delegate')).toBe('wrench'); }); test('returns share for tools ending with -with-agent', () => { @@ -84,7 +84,7 @@ describe('getToolIcon', () => { }); test('treats removed plan tool as an ordinary unknown icon', () => { - expect(getToolIcon('plan')).toBe('settings'); + expect(getToolIcon('plan')).toBe('wrench'); }); test('returns key-round for credential tools', () => { @@ -110,8 +110,8 @@ describe('getToolIcon', () => { expect(getToolIcon('n8n-docs')).toBe('book-open'); }); - test('returns settings as default', () => { - expect(getToolIcon('unknown-tool')).toBe('settings'); + test('returns wrench as default', () => { + expect(getToolIcon('unknown-tool')).toBe('wrench'); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTimelineGrouping.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTimelineGrouping.test.ts deleted file mode 100644 index 4ee6fbab40e..00000000000 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useTimelineGrouping.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { InstanceAiAgentNode, InstanceAiToolCallState } from '@n8n/api-types'; -import { describe, expect, test } from 'vitest'; -import { ref } from 'vue'; -import { useTimelineGrouping } from '../useTimelineGrouping'; - -function makeToolCall(overrides: Partial): InstanceAiToolCallState { - return { - toolCallId: 'tc-1', - toolName: 'build-workflow', - args: {}, - isLoading: false, - ...overrides, - }; -} - -function makeAgentNode(overrides: Partial): InstanceAiAgentNode { - return { - agentId: 'agent-1', - role: 'orchestrator', - status: 'completed', - textContent: '', - reasoning: '', - toolCalls: [], - children: [], - timeline: [], - ...overrides, - }; -} - -describe('useTimelineGrouping', () => { - test('surfaces artifacts from root tool calls', () => { - const segments = useTimelineGrouping( - ref( - makeAgentNode({ - toolCalls: [ - makeToolCall({ - renderHint: 'builder', - result: { workflowId: 'wf-1', workflowName: 'Built workflow' }, - completedAt: '2026-01-01T00:00:00Z', - }), - ], - timeline: [{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' }], - }), - ), - ).value; - - if (!segments || segments[0].kind !== 'response-group') { - throw new Error('Expected a response group'); - } - expect(segments[0].artifacts).toEqual([ - { - type: 'workflow', - resourceId: 'wf-1', - name: 'Built workflow', - completedAt: '2026-01-01T00:00:00Z', - }, - ]); - }); - - test('groups reasoning with its step tool calls and counts it', () => { - const segments = useTimelineGrouping( - ref( - makeAgentNode({ - toolCalls: [makeToolCall({ toolCallId: 'tc-1', toolName: 'search' })], - timeline: [ - { type: 'reasoning', content: 'plan the search', responseId: 'r-1' }, - { type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' }, - { type: 'text', content: 'the answer', responseId: 'r-2' }, - ], - }), - ), - ).value; - - if (!segments) throw new Error('Expected segments'); - expect(segments).toHaveLength(2); - const group = segments[0]; - if (group.kind !== 'response-group') throw new Error('Expected a response group'); - expect(group.reasoningCount).toBe(1); - expect(group.entries).toEqual([ - { type: 'reasoning', content: 'plan the search', responseId: 'r-1' }, - { type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' }, - ]); - expect(segments[1]).toEqual({ kind: 'trailing-text', content: 'the answer' }); - }); - - test('keeps reasoning-only groups', () => { - const segments = useTimelineGrouping( - ref( - makeAgentNode({ - timeline: [ - { type: 'reasoning', content: 'final thoughts', responseId: 'r-1' }, - { type: 'text', content: 'the answer', responseId: 'r-1' }, - ], - }), - ), - ).value; - - if (!segments) throw new Error('Expected segments'); - expect(segments).toHaveLength(2); - const group = segments[0]; - if (group.kind !== 'response-group') throw new Error('Expected a response group'); - expect(group.reasoningCount).toBe(1); - expect(group.entries).toEqual([ - { type: 'reasoning', content: 'final thoughts', responseId: 'r-1' }, - ]); - expect(segments[1]).toEqual({ kind: 'trailing-text', content: 'the answer' }); - }); - - test('deduplicates artifacts across root tool calls', () => { - const segments = useTimelineGrouping( - ref( - makeAgentNode({ - toolCalls: [ - makeToolCall({ - toolCallId: 'tc-1', - renderHint: 'builder', - result: { workflowId: 'wf-1', workflowName: 'Built workflow' }, - completedAt: '2026-01-01T00:00:00Z', - }), - makeToolCall({ - toolCallId: 'tc-2', - renderHint: 'builder', - result: { workflowId: 'wf-1', workflowName: 'Built workflow' }, - completedAt: '2026-01-01T00:00:01Z', - }), - ], - timeline: [ - { type: 'tool-call', toolCallId: 'tc-1', responseId: 'r-1' }, - { type: 'tool-call', toolCallId: 'tc-2', responseId: 'r-1' }, - ], - }), - ), - ).value; - - if (!segments || segments[0].kind !== 'response-group') { - throw new Error('Expected a response group'); - } - expect(segments[0].artifacts).toHaveLength(1); - expect(segments[0].artifacts[0].resourceId).toBe('wf-1'); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts index e32daeeb600..1ae82fd3488 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/agentTimeline.utils.ts @@ -10,38 +10,191 @@ export const HIDDEN_TOOLS = new Set(['updateWorkingMemory']); /** Render hints whose tool calls produce no output in the timeline — they are * represented elsewhere (child agent sections, artifact cards). */ -const INVISIBLE_RENDER_HINTS = new Set(['builder', 'data-table', 'eval-setup']); +const INVISIBLE_RENDER_HINTS = new Set(['data-table', 'eval-setup']); + +/** Streamed tail text up to this length is treated as thinking narration; + * beyond it, it is likely the final answer and renders outside the block. */ +const TAIL_NARRATION_MAX_LENGTH = 200; + +type TextEntry = Extract; + +/** First sentence of a streamed markdown-ish text, for trace status lines. */ +export function firstSentence(content: string): string { + const plain = content.replace(/[*_`#]/g, '').trim(); + const match = plain.match(/^.*?[.!?](?=\s|$)/s); + return (match ? match[0] : plain).trim(); +} /** - * True when a timeline entry produces visible output in `AgentTimeline`. + * The timeline reduced to renderable blocks. * - * Mirrors the template's branch chain (same order): hidden tools and - * builder/data-table/eval-setup hints render nothing; plan-review - * confirmations render a panel; bare planner calls render nothing; pending - * question forms are suppressed until answered; hoisted active builder - * children are rendered elsewhere. Used to skip the timeline wrapper - * entirely when a segment has no visible entries (avoids empty divs and - * their phantom flex-gap spacing). + * A `thinking` block is a maximal run of trace content — reasoning segments, + * generic tool calls, and the intermediate narration text the model emits + * between them — split only by user-facing content (answer text, plan + * reviews, answered questions, task checklists, child agents). Invisible + * entries (hidden tools, builder/planner hints, pending questions) are + * dropped without splitting a run. */ -export function isVisibleTimelineEntry( - entry: InstanceAiTimelineEntry, +export type TimelineBlock = + | { type: 'thinking'; key: string; entries: InstanceAiTimelineEntry[]; active: boolean } + | { type: 'text'; key: string; entry: TextEntry } + | { type: 'tasks'; key: string; toolCall: InstanceAiToolCallState } + | { type: 'plan-review'; key: string; toolCall: InstanceAiToolCallState } + | { type: 'questions'; key: string; toolCall: InstanceAiToolCallState } + | { type: 'child'; key: string; child: InstanceAiAgentNode }; + +type ToolCallKind = + | 'hidden' + | 'tasks' + | 'plan-review' + | 'questions' + | 'questions-pending' + | 'trace'; + +/** + * How a tool call renders in the timeline. `trace` rows join thinking blocks; + * `tasks`/`plan-review`/`questions` render standalone UI; `hidden` calls are + * dropped without splitting a run. + * + * Builder calls delegated to a sub-agent (`*-with-agent`) are hidden — the + * child agent section represents them. In-thread builds (`build-workflow`) + * render as trace rows so the build step is visible inside the thinking block. + */ +function classifyToolCall(tc: InstanceAiToolCallState): ToolCallKind { + if (HIDDEN_TOOLS.has(tc.toolName)) return 'hidden'; + if (tc.renderHint === 'tasks') return 'tasks'; + if (tc.renderHint === 'builder' && tc.toolName.endsWith('-with-agent')) return 'hidden'; + if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return 'hidden'; + if (tc.confirmation?.inputType === 'plan-review') return 'plan-review'; + if (tc.renderHint === 'planner') return 'hidden'; + if (tc.confirmation?.inputType === 'questions') { + return tc.isLoading ? 'questions-pending' : 'questions'; + } + return 'trace'; +} + +export function buildTimelineBlocks( + entries: InstanceAiTimelineEntry[], toolCallsById: Record, childrenById: Record, -): boolean { - if (entry.type === 'text' || entry.type === 'reasoning') return true; + agentStatus: InstanceAiAgentNode['status'], +): TimelineBlock[] { + // A text entry is intermediate narration (part of the thinking trace) when + // trace content from the SAME model response follows it — i.e. the model + // kept working after writing it. Trailing text of a response (and text + // without a responseId, from old snapshots) is user-facing. + const lastTraceIdxByResponse = new Map(); + entries.forEach((entry, idx) => { + if (entry.responseId === undefined) return; + if (entry.type === 'reasoning') { + lastTraceIdxByResponse.set(entry.responseId, idx); + } else if (entry.type === 'tool-call') { + const tc = toolCallsById[entry.toolCallId]; + if (tc && classifyToolCall(tc) === 'trace') { + lastTraceIdxByResponse.set(entry.responseId, idx); + } + } + }); + + const isIntermediateText = (entry: TextEntry, idx: number): boolean => { + if (entry.responseId === undefined) return false; + const lastTraceIdx = lastTraceIdxByResponse.get(entry.responseId); + if (lastTraceIdx !== undefined && lastTraceIdx > idx) return true; + // Streaming tail text is ambiguous — narration before the next tool call + // and the final answer look identical until either a tool call follows + // or the run ends. Rendering it outside and folding it back in reads as + // an answer flashing and vanishing, so short tail text following this + // response's trace content is optimistically kept INSIDE the block (its + // first sentence feeds the status line). It promotes out — an additive, + // non-jarring motion — once it grows answer-length or the run settles. + return ( + agentStatus === 'active' && + idx === entries.length - 1 && + lastTraceIdx !== undefined && + entry.content.length <= TAIL_NARRATION_MAX_LENGTH + ); + }; + + const blocks: TimelineBlock[] = []; + let run: Extract | null = null; + + const pushStandalone = (block: TimelineBlock) => { + run = null; + blocks.push(block); + }; + + const pushTrace = (entry: InstanceAiTimelineEntry, idx: number) => { + if (!run) { + run = { type: 'thinking', key: `thinking-${idx}`, entries: [], active: false }; + blocks.push(run); + } + run.entries.push(entry); + }; + + entries.forEach((entry, idx) => { + if (entry.type === 'reasoning') { + pushTrace(entry, idx); + return; + } + + if (entry.type === 'text') { + if (isIntermediateText(entry, idx)) pushTrace(entry, idx); + else pushStandalone({ type: 'text', key: `text-${idx}`, entry }); + return; + } + + if (entry.type === 'child') { + const child = childrenById[entry.agentId]; + // Running builder sub-agents are extracted and rendered at the bottom + // of the conversation by InstanceAiView; once a builder finishes it + // reappears here in its chronological slot. + if (child && !isActiveBuilderAgent(child)) { + pushStandalone({ type: 'child', key: `child-${idx}`, child }); + } + return; + } - if (entry.type === 'tool-call') { const tc = toolCallsById[entry.toolCallId]; - if (!tc || HIDDEN_TOOLS.has(tc.toolName)) return false; - if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return false; - if (tc.confirmation?.inputType === 'plan-review') return true; - if (tc.renderHint === 'planner') return false; - if (tc.confirmation?.inputType === 'questions') return !tc.isLoading; - return true; + if (!tc) return; + switch (classifyToolCall(tc)) { + case 'tasks': + pushStandalone({ type: 'tasks', key: `tasks-${idx}`, toolCall: tc }); + return; + case 'plan-review': + pushStandalone({ type: 'plan-review', key: `plan-${idx}`, toolCall: tc }); + return; + case 'questions': + pushStandalone({ type: 'questions', key: `questions-${idx}`, toolCall: tc }); + return; + case 'trace': + pushTrace(entry, idx); + return; + // hidden and pending question forms drop without splitting the run + default: + return; + } + }); + + // While the agent streams, the trailing thinking block stays active as long + // as only (tentative) text follows it — tail text may still fold back into + // the block if same-response trace content arrives, so settling the block + // early would flicker "Thought for Xs" → "Thinking". Real interruptions + // (cards, questions, child agents) settle it immediately, and so does text + // that outgrew the narration cap: it is a committed answer, so keeping the + // block behind it "thinking" reads as lag. + if (agentStatus === 'active') { + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.type === 'thinking') { + block.active = true; + break; + } + if (block.type !== 'text') break; + if (block.entry.content.length > TAIL_NARRATION_MAX_LENGTH) break; + } } - const child = childrenById[entry.agentId]; - return child !== undefined && !isActiveBuilderAgent(child); + return blocks; } /** diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue index de33ec1c06b..a4a0b1a69b5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue @@ -1,92 +1,17 @@ - - diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue index 3c9a7f86c73..5eb934f0258 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue @@ -8,33 +8,26 @@ import type { import { useI18n } from '@n8n/i18n'; import { computed } from 'vue'; import { + buildTimelineBlocks, extractArtifacts, isStreamingTimelineEntry, - isVisibleTimelineEntry, - HIDDEN_TOOLS, type ArtifactInfo, } from '../agentTimeline.utils'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useThread } from '../instanceAi.store'; -import { isActiveBuilderAgent } from '../builderAgents'; import AgentSection from './AgentSection.vue'; import AnsweredQuestions from './AnsweredQuestions.vue'; import ArtifactCard from './ArtifactCard.vue'; import PlanReviewPanel, { type PlannedTaskArg, type PlanReviewStatus } from './PlanReviewPanel.vue'; -import ReasoningBlock from './ReasoningBlock.vue'; import TaskChecklist from './TaskChecklist.vue'; +import ThinkingBlock from './ThinkingBlock.vue'; import TimelineTextSegment from './TimelineTextSegment.vue'; -import ToolResultJson from './ToolResultJson.vue'; -import ToolResultRenderer from './ToolResultRenderer.vue'; -import { N8nAiActivityStep as ToolCallStep } from '@n8n/design-system'; -import { useToolLabel } from '../toolLabels'; const i18n = useI18n(); const thread = useThread(); const telemetry = useTelemetry(); const rootStore = useRootStore(); -const { getToolLabel } = useToolLabel(); /** Resolve artifact name from the enriched registry (falls back to extracted name). */ function resolveArtifactName(artifact: ArtifactInfo): string { @@ -83,7 +76,7 @@ function formatArtifactMetadata(artifact: ArtifactInfo): string { parts.push(i18n.baseText('instanceAi.artifactCard.updatedJustNow')); } - return parts.join(' \u2502 '); + return parts.join(' │ '); } const props = withDefaults( @@ -110,6 +103,24 @@ const toolCallsById = computed(() => { return map; }); +/** Index children by agentId for O(1) lookup and proper reactivity tracking. */ +const childrenById = computed(() => { + const map: Record = {}; + for (const child of props.agentNode.children) { + map[child.agentId] = child; + } + return map; +}); + +const renderBlocks = computed(() => + buildTimelineBlocks( + timelineEntries.value, + toolCallsById.value, + childrenById.value, + props.agentNode.status, + ), +); + function getPlanTasks(tc: InstanceAiToolCallState): PlannedTaskArg[] { return ( tc.confirmation?.planItems ?? @@ -153,26 +164,6 @@ function isPlanCardReadOnly(tc: InstanceAiToolCallState): boolean { return false; } -/** Index children by agentId for O(1) lookup and proper reactivity tracking. */ -const childrenById = computed(() => { - const map: Record = {}; - for (const child of props.agentNode.children) { - map[child.agentId] = child; - } - return map; -}); - -/** - * Whether any entry renders visible output. Segments made up entirely of - * hidden tool calls (e.g. builder calls represented by artifact cards) skip - * the wrapper div — an empty flex item would still add gap spacing. - */ -const hasVisibleEntries = computed(() => - timelineEntries.value.some((entry) => - isVisibleTimelineEntry(entry, toolCallsById.value, childrenById.value), - ), -); - function handlePlanApprove(tc: InstanceAiToolCallState) { const requestId = tc.confirmation?.requestId; if (!requestId) return; @@ -255,109 +246,52 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi