import type { BuiltTool, StreamChunk } from '@n8n/agents';

import { buildToolCallDetails, ExecutionRecorder, type TimelineEvent } from '../execution-recorder';
import { buildToolRegistry } from '../tool-registry';

function makeToolCallChunk(toolName: string, input: unknown, toolCallId = 'tc1'): StreamChunk {
	return { type: 'tool-call', toolCallId, toolName, input } satisfies StreamChunk;
}

function makeToolResultChunk(toolName: string, output: unknown, toolCallId = 'tc1'): StreamChunk {
	return { type: 'tool-result', toolCallId, toolName, output } satisfies StreamChunk;
}

describe('ExecutionRecorder', () => {
	it('builds full node tool details when the model input is empty', () => {
		const registry = buildToolRegistry([
			{
				name: 'check_ledger',
				description: 'Read rows from the configured ledger table',
				metadata: {
					kind: 'node',
					nodeType: 'n8n-nodes-base.dataTableTool',
					nodeTypeVersion: 1.1,
					displayName: 'Check ledger',
					nodeParameters: {
						resource: 'row',
						operation: 'get',
						dataTableId: { mode: 'id', value: 'table-1' },
						returnAll: true,
					},
				},
			} satisfies BuiltTool,
		]);

		expect(buildToolCallDetails(registry, 'check_ledger', {})).toEqual({
			toolName: 'check_ledger',
			displayName: 'Check ledger',
			kind: 'node',
			input: {},
			node: {
				type: 'n8n-nodes-base.dataTableTool',
				typeVersion: 1.1,
				parameters: {
					resource: 'row',
					operation: 'get',
					dataTableId: { mode: 'id', value: 'table-1' },
					returnAll: true,
				},
			},
		});
	});

	describe('per-tool execution timing', () => {
		afterEach(() => {
			vi.useRealTimers();
		});

		it('records distinct per-tool end times from tool-execution-end for concurrent tools', () => {
			vi.useFakeTimers();
			vi.setSystemTime(1_000);
			const recorder = new ExecutionRecorder();

			// Two concurrent tool calls emitted together by the model.
			recorder.record(makeToolCallChunk('a', {}, 'tc-1'));
			recorder.record(makeToolCallChunk('b', {}, 'tc-2'));

			// Both start executing together (server-stamped on the chunk).
			recorder.record({
				type: 'tool-execution-start',
				toolCallId: 'tc-1',
				toolName: 'a',
				startTime: 1_100,
			});
			recorder.record({
				type: 'tool-execution-start',
				toolCallId: 'tc-2',
				toolName: 'b',
				startTime: 1_100,
			});

			// They finish at different real times (server-stamped on the chunk).
			recorder.record({
				type: 'tool-execution-end',
				toolCallId: 'tc-1',
				toolName: 'a',
				isError: false,
				endTime: 1_500,
			});
			recorder.record({
				type: 'tool-execution-end',
				toolCallId: 'tc-2',
				toolName: 'b',
				isError: false,
				endTime: 3_000,
			});

			// The batched tool-results arrive together, after the slowest finished.
			vi.setSystemTime(3_001);
			recorder.record(makeToolResultChunk('a', 'ra', 'tc-1'));
			recorder.record(makeToolResultChunk('b', 'rb', 'tc-2'));
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const { timeline } = recorder.getMessageRecord();
			const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1');
			const b = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-2');

			// startTime from tool-execution-start, endTime from tool-execution-end —
			// NOT the shared batched tool-result timestamp (3001).
			expect(a).toMatchObject({ startTime: 1_100, endTime: 1_500, output: 'ra', success: true });
			expect(b).toMatchObject({ startTime: 1_100, endTime: 3_000, output: 'rb', success: true });
		});

		it('falls back to the tool-result time when tool-execution-end is absent', () => {
			vi.useFakeTimers();
			vi.setSystemTime(1_000);
			const recorder = new ExecutionRecorder();

			recorder.record(makeToolCallChunk('a', {}, 'tc-1'));
			vi.setSystemTime(2_000);
			recorder.record(makeToolResultChunk('a', 'ra', 'tc-1'));
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const { timeline } = recorder.getMessageRecord();
			const a = timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-1');
			expect(a).toMatchObject({ endTime: 2_000, output: 'ra', success: true });
		});
	});

	describe('timeline ordering', () => {
		afterEach(() => {
			vi.useRealTimers();
		});

		it('records reasoning with timing without adding it to the assistant response', () => {
			vi.useFakeTimers();
			vi.setSystemTime(1_000);
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'reasoning-start', id: 'r1' });
			recorder.record({ type: 'reasoning-delta', id: 'r1', delta: 'Check the inputs. ' });
			vi.setSystemTime(1_500);
			recorder.record({ type: 'reasoning-delta', id: 'r1', delta: 'Then answer.' });
			vi.setSystemTime(2_000);
			recorder.record({ type: 'reasoning-end', id: 'r1' });
			recorder.record({ type: 'text-delta', id: 't1', delta: 'Done' });
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(record.timeline).toEqual([
				{
					type: 'reasoning',
					content: 'Check the inputs. Then answer.',
					timestamp: 1_000,
					endTime: 2_000,
				},
				{ type: 'text', content: 'Done', timestamp: 2_000, endTime: 2_000 },
			]);
			expect(record.assistantResponse).toBe('Done');
		});

		it('keeps partial reasoning when the stream errors', () => {
			vi.useFakeTimers();
			vi.setSystemTime(1_000);
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'reasoning-start', id: 'r1' });
			recorder.record({ type: 'reasoning-delta', id: 'r1', delta: 'Partial analysis' });
			vi.setSystemTime(2_000);
			recorder.record({ type: 'error', error: new Error('Stream failed') });

			expect(recorder.getMessageRecord()).toEqual(
				expect.objectContaining({
					assistantResponse: '',
					error: 'Stream failed',
					timeline: [
						{
							type: 'reasoning',
							content: 'Partial analysis',
							timestamp: 1_000,
							endTime: 2_000,
						},
					],
				}),
			);
		});

		it('keeps the first stream error when a later wrapper error arrives', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'error', error: new Error('Thinking blocks cannot be modified') });
			recorder.record({
				type: 'error',
				error: new Error('No output generated. Check the stream for errors.'),
			});

			expect(recorder.getMessageRecord().error).toBe('Thinking blocks cannot be modified');
		});

		it('captures text → tool call → text in order', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'text-delta', id: 't1', delta: 'Let me check' });
			recorder.record(makeToolCallChunk('lookup', { query: 'test' }));
			recorder.record(makeToolResultChunk('lookup', { found: true }));
			recorder.record({ type: 'text-delta', id: 't2', delta: 'Here are the results' });
			recorder.record({
				type: 'finish',
				finishReason: 'stop',
			} as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(record.timeline).toHaveLength(3);
			expect(record.timeline[0].type).toBe('text');
			expect(record.timeline[1].type).toBe('tool-call');
			expect(record.timeline[2].type).toBe('text');

			if (record.timeline[0].type === 'text') {
				expect(record.timeline[0].content).toBe('Let me check');
			}
			if (record.timeline[1].type === 'tool-call') {
				expect(record.timeline[1].name).toBe('lookup');
				expect(record.timeline[1].output).toEqual({ found: true });
				expect(record.timeline[1].success).toBe(true);
			}
			if (record.timeline[2].type === 'text') {
				expect(record.timeline[2].content).toBe('Here are the results');
			}
		});

		it('captures multiple tool calls between text segments', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'text-delta', id: 't1', delta: 'Checking...' });
			recorder.record(makeToolCallChunk('tool_a', {}, 'a1'));
			recorder.record(makeToolResultChunk('tool_a', 'result_a', 'a1'));
			recorder.record(makeToolCallChunk('tool_b', {}, 'b1'));
			recorder.record(makeToolResultChunk('tool_b', 'result_b', 'b1'));
			recorder.record({ type: 'text-delta', id: 't2', delta: 'Done' });
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(record.timeline).toHaveLength(4);
			expect(record.timeline.map((e) => e.type)).toEqual([
				'text',
				'tool-call',
				'tool-call',
				'text',
			]);
		});

		it('does not create empty text events for whitespace-only segments', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'text-delta', id: 't1', delta: '   ' });
			recorder.record(makeToolCallChunk('lookup', {}));
			recorder.record(makeToolResultChunk('lookup', {}));
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();
			const textEvents = record.timeline.filter((e) => e.type === 'text');
			expect(textEvents).toHaveLength(0);
		});
	});

	describe('suspension', () => {
		it('records sanitized HITL request details on the suspension event', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'text-delta', id: 't1', delta: 'Choose an option' });
			recorder.record({
				type: 'tool-call-suspended',
				toolName: 'slack_action',
				toolCallId: 'tc1',
				input: { channel: 'approvals', apiKey: 'secret-key' },
				suspendPayload: {
					type: 'approval',
					toolName: 'slack_action',
					args: { channel: 'approvals', password: 'secret-password' },
				},
			} as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(recorder.suspended).toBe(true);
			expect(record.timeline.find((event) => event.type === 'suspension')).toMatchObject({
				type: 'suspension',
				toolName: 'slack_action',
				toolCallId: 'tc1',
				input: { channel: 'approvals', apiKey: '[REDACTED]' },
				suspendPayload: {
					type: 'approval',
					toolName: 'slack_action',
					args: { channel: 'approvals', password: '[REDACTED]' },
				},
			});
		});

		it('records a sanitized HITL response as a distinct event', () => {
			const recorder = new ExecutionRecorder();

			recorder.recordHitlResponse('tc1', {
				approved: true,
				credentials: { apiKey: 'secret-key' },
			});

			expect(recorder.getMessageRecord().timeline).toEqual([
				expect.objectContaining({
					type: 'hitl-response',
					toolCallId: 'tc1',
					response: {
						approved: true,
						credentials: '[REDACTED]',
					},
				}),
			]);
		});
	});

	describe('message record', () => {
		it('concatenates assistantResponse from all text deltas', () => {
			const recorder = new ExecutionRecorder();

			recorder.record({ type: 'text-delta', id: 't1', delta: 'Hello ' });
			recorder.record(makeToolCallChunk('tool', {}));
			recorder.record(makeToolResultChunk('tool', {}));
			recorder.record({ type: 'text-delta', id: 't2', delta: 'world' });
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();
			expect(record.assistantResponse).toBe('Hello world');
		});
	});

	describe('secret scrubbing', () => {
		it('sanitizes tool inputs and outputs in timeline entries', () => {
			const recorder = new ExecutionRecorder();

			recorder.record(
				makeToolCallChunk('lookup', {
					query: 'project status',
					password: 'plain-secret-password',
					nested: { apiKey: 'secret-api-key' },
				}),
			);
			recorder.record(
				makeToolResultChunk('lookup', {
					result: 'password=hunter2',
					authorization: 'Bearer secret-token-value',
				}),
			);

			const record = recorder.getMessageRecord();
			const timelineEntry = record.timeline.find((e) => e.type === 'tool-call');

			expect(timelineEntry).toMatchObject({
				input: {
					query: 'project status',
					password: '[REDACTED]',
					nested: { apiKey: '[REDACTED]' },
				},
				output: {
					result: '[REDACTED]',
					authorization: '[REDACTED]',
				},
			});
		});

		it('sanitizes error outputs before recording them', () => {
			const recorder = new ExecutionRecorder();

			recorder.record(makeToolCallChunk('lookup', { query: 'project status' }));
			recorder.record({
				type: 'tool-result',
				toolCallId: 'tc1',
				toolName: 'lookup',
				output: new Error('password=hunter2'),
				isError: true,
			});

			const record = recorder.getMessageRecord();
			const timelineEntry = record.timeline.find((e) => e.type === 'tool-call');

			expect(timelineEntry).toMatchObject({ output: { error: '[REDACTED]' } });
		});
	});
});

function wfTool(name: string, id: string, wfName: string, trigger = 'manual'): BuiltTool {
	return {
		name,
		metadata: { kind: 'workflow', workflowId: id, workflowName: wfName, triggerType: trigger },
	} as unknown as BuiltTool;
}

function nodeTool(
	name: string,
	nodeType: string,
	nodeParameters: Record<string, unknown>,
): BuiltTool {
	return {
		name,
		metadata: { kind: 'node', nodeType, nodeTypeVersion: 1, displayName: name, nodeParameters },
	} as unknown as BuiltTool;
}

describe('ExecutionRecorder — node-tool $fromAI resolution', () => {
	it('substitutes a full-string $fromAI expression with the LLM-provided value', () => {
		const registry = buildToolRegistry([
			nodeTool('generate_image', '@n8n/n8n-nodes-langchain.openAi', {
				resource: 'image',
				operation: 'generate',
				prompt: "={{ $fromAI('prompt', 'Image description', 'string') }}",
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'generate_image',
			input: { prompt: 'a rolling landscape' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'generate_image',
			output: { status: 'success' },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.kind).toBe('node');
		expect(tc.nodeParameters).toEqual({
			resource: 'image',
			operation: 'generate',
			prompt: 'a rolling landscape',
		});
	});

	it('falls back to the $fromAI default when the LLM did not provide the key', () => {
		const registry = buildToolRegistry([
			nodeTool('send_message', 'n8n-nodes-base.slack', {
				channel: "={{ $fromAI('channel', 'Channel name', 'string', 'general') }}",
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'send_message',
			input: {},
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'send_message',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect((tc.nodeParameters as Record<string, unknown>).channel).toBe('general');
	});

	it('substitutes $fromAI inside an auto-generated-marker template', () => {
		const registry = buildToolRegistry([
			nodeTool('search', 'n8n-nodes-base.http', {
				url: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('query', 'Search term', 'string') }}",
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'search',
			input: { query: 'dragons' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'search',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect((tc.nodeParameters as Record<string, unknown>).url).toBe('dragons');
	});

	it('walks nested objects in nodeParameters and resolves each $fromAI', () => {
		const registry = buildToolRegistry([
			nodeTool('image', '@n8n/n8n-nodes-langchain.openAi', {
				options: {
					size: "={{ $fromAI('size', 'Image size', 'string', '1024x1024') }}",
					nested: { nested2: "={{ $fromAI('quality', 'Quality', 'string', 'medium') }}" },
				},
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'image',
			input: { size: '512x512', quality: 'high' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'image',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		const params = tc.nodeParameters as Record<string, Record<string, unknown>>;
		expect(params.options.size).toBe('512x512');
		expect((params.options.nested as Record<string, unknown>).nested2).toBe('high');
	});

	it('leaves the raw template in place when extraction fails', () => {
		const registry = buildToolRegistry([
			nodeTool('broken', 'n8n-nodes-base.set', {
				field: '={{ $fromAI(unbalanced ',
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'broken',
			input: {},
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'broken',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect((tc.nodeParameters as Record<string, unknown>).field).toBe('={{ $fromAI(unbalanced ');
	});

	it('sanitizes resolved node parameters before recording them', () => {
		const registry = buildToolRegistry([
			nodeTool('send_secret', 'n8n-nodes-base.http', {
				password: "={{ $fromAI('password', 'Password', 'string') }}",
				body: {
					apiKey: "={{ $fromAI('apiKey', 'API key', 'string') }}",
					message: "={{ $fromAI('message', 'Message', 'string') }}",
				},
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'send_secret',
			input: {
				password: 'plain-secret-password',
				apiKey: 'secret-api-key',
				message: 'visible',
			},
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'send_secret',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.nodeParameters).toEqual({
			password: '[REDACTED]',
			body: {
				apiKey: '[REDACTED]',
				message: 'visible',
			},
		});
	});
});

describe('ExecutionRecorder — workflow-tool timeline tags', () => {
	it('tags a tool-call entry as kind:tool when no registry is provided', () => {
		const rec = new ExecutionRecorder();
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'http', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'http',
			output: { ok: true },
			isError: false,
		} as never);
		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.kind).toBe('tool');
		expect(tc.workflowId).toBeUndefined();
	});

	it('tags a tool-call as kind:workflow and stores workflowId/Name/triggerType when registry matches', () => {
		const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]);
		const rec = new ExecutionRecorder(registry);
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'run-wf', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'run-wf',
			output: { executionId: 'e-42', status: 'success' },
			isError: false,
		} as never);
		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.kind).toBe('workflow');
		expect(tc.workflowId).toBe('wf-1');
		expect(tc.workflowName).toBe('Run WF');
		expect(tc.triggerType).toBe('manual');
		expect(tc.workflowExecutionId).toBe('e-42');
	});

	it('leaves workflowExecutionId undefined for form-trigger workflow tools', () => {
		const registry = buildToolRegistry([wfTool('fill-form', 'wf-2', 'Fill Form', 'form')]);
		const rec = new ExecutionRecorder(registry);
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'fill-form', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'fill-form',
			output: { status: 'form_link_sent', formUrl: 'https://x/form' },
			isError: false,
		} as never);
		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.kind).toBe('workflow');
		expect(tc.triggerType).toBe('form');
		expect(tc.workflowExecutionId).toBeUndefined();
	});

	it('synthesizes a tool-call timeline entry when tool-result arrives without a preceding tool-call', () => {
		const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]);
		const rec = new ExecutionRecorder(registry);

		// HITL/approval resume: SDK replays the result without a tool-call chunk
		rec.record({
			type: 'tool-result',
			toolCallId: 't-resume',
			toolName: 'run-wf',
			output: { executionId: 'e-99', status: 'success' },
			isError: false,
		} as never);
		rec.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

		const record = rec.getMessageRecord();
		const tc = record.timeline.find((e) => e.type === 'tool-call');

		expect(tc).toBeDefined();
		expect(tc?.kind).toBe('workflow');
		expect(tc?.name).toBe('run-wf');
		expect(tc?.toolCallId).toBe('t-resume');
		expect(tc?.workflowId).toBe('wf-1');
		expect(tc?.workflowName).toBe('Run WF');
		expect(tc?.workflowExecutionId).toBe('e-99');
		expect(tc?.success).toBe(true);
	});

	it('leaves workflowExecutionId undefined when the output is an error with no executionId', () => {
		const registry = buildToolRegistry([wfTool('run-wf', 'wf-1', 'Run WF')]);
		const rec = new ExecutionRecorder(registry);
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'run-wf', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'run-wf',
			output: { error: 'Kaboom' },
			isError: true,
		} as never);
		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.workflowExecutionId).toBeUndefined();
		expect(tc.success).toBe(false);
	});
});

describe('ExecutionRecorder — tool-result error normalization', () => {
	it('captures the Error message when a tool throws (instead of storing a non-serializable Error)', () => {
		const rec = new ExecutionRecorder();
		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'send_telegram_message',
			input: { chatId: '1', text: 'hi' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'send_telegram_message',
			output: new Error('Credential "Telegram acc" is not accessible or does not exist.'),
			isError: true,
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.success).toBe(false);
		// `tc.output` must be a plain enumerable object — a raw Error would serialize
		// to "{}" via TypeORM's JSON column, losing the diagnostic the UI needs.
		expect(tc.output).toEqual({
			error: 'Credential "Telegram acc" is not accessible or does not exist.',
		});
	});

	it('wraps a string error output in { error } so the shape stays consistent', () => {
		const rec = new ExecutionRecorder();
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'x', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'x',
			output: 'boom',
			isError: true,
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.output).toEqual({ error: 'boom' });
	});

	it('preserves an already-enumerable error object', () => {
		const rec = new ExecutionRecorder();
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'x', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'x',
			output: { error: 'already shaped', code: 'E_BAD' },
			isError: true,
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.output).toEqual({ error: 'already shaped', code: 'E_BAD' });
	});

	it('normalises Error in the synthesised entry path too', () => {
		// HITL/approval resume path: tool-result arrives without a preceding
		// tool-call chunk, so the recorder synthesises a timeline entry. That
		// path must apply the same normalisation.
		const rec = new ExecutionRecorder();
		rec.record({
			type: 'tool-result',
			toolCallId: 't-resume',
			toolName: 'x',
			output: new Error('still bad'),
			isError: true,
		} as never);
		rec.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.success).toBe(false);
		expect(tc.output).toEqual({ error: 'still bad' });
	});

	it('leaves a successful tool result untouched', () => {
		const rec = new ExecutionRecorder();
		rec.record({ type: 'tool-call', toolCallId: 't1', toolName: 'x', input: {} } as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'x',
			output: { status: 'ok', data: [1, 2, 3] },
			isError: false,
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.success).toBe(true);
		expect(tc.output).toEqual({ status: 'ok', data: [1, 2, 3] });
	});

	it('preserves object-shaped stream errors in the message record', () => {
		const rec = new ExecutionRecorder();
		rec.record({
			type: 'error',
			error: {
				message: 'Node tool validation failed',
				code: 'NODE_TOOL_VALIDATION',
				details: { nodeType: 'n8n-nodes-base.httpRequestTool' },
			},
		} as never);
		rec.record({ type: 'finish', finishReason: 'error' } as StreamChunk);

		const record = rec.getMessageRecord();

		expect(record.error).toContain('Node tool validation failed');
		expect(record.error).toContain('NODE_TOOL_VALIDATION');
		expect(record.error).toContain('n8n-nodes-base.httpRequestTool');
	});

	it('scrubs secrets from Error-shaped stream errors', () => {
		const rec = new ExecutionRecorder();
		rec.record({
			type: 'error',
			error: new Error('Request failed with apiKey=super-secret-token'),
		} as never);
		rec.record({ type: 'finish', finishReason: 'error' } as StreamChunk);

		const record = rec.getMessageRecord();

		expect(record.error).toBe('Request failed with [REDACTED]');
	});
});

describe('ExecutionRecorder — node-tool {{$json.x}} resolution', () => {
	it('resolves a full-string {{$json.path}} expression using the LLM args', () => {
		const registry = buildToolRegistry([
			nodeTool('send_message', 'n8n-nodes-base.telegramTool', {
				resource: 'message',
				operation: 'sendMessage',
				chatId: '={{$json.chat_id}}',
				text: '={{ $json.text }}',
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'send_message',
			input: { chat_id: '12345', text: 'hello there' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'send_message',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.nodeParameters).toEqual({
			resource: 'message',
			operation: 'sendMessage',
			chatId: '12345',
			text: 'hello there',
		});
	});

	it('resolves a nested {{$json.path.sub}} lookup', () => {
		const registry = buildToolRegistry([
			nodeTool('post', 'n8n-nodes-base.http', {
				url: '={{ $json.target.url }}',
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'post',
			input: { target: { url: 'https://example.com/api' } },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'post',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect((tc.nodeParameters as Record<string, unknown>).url).toBe('https://example.com/api');
	});

	it('leaves the raw template in place when the path is missing', () => {
		const registry = buildToolRegistry([
			nodeTool('post', 'n8n-nodes-base.http', {
				url: '={{ $json.target.url }}',
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'post',
			input: { target: {} },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'post',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect((tc.nodeParameters as Record<string, unknown>).url).toBe('={{ $json.target.url }}');
	});

	it('still resolves $fromAI calls when both styles are mixed', () => {
		const registry = buildToolRegistry([
			nodeTool('post', 'n8n-nodes-base.http', {
				url: "={{ $fromAI('endpoint', 'API endpoint', 'string') }}",
				body: '={{ $json.payload }}',
			}),
		]);
		const rec = new ExecutionRecorder(registry);

		rec.record({
			type: 'tool-call',
			toolCallId: 't1',
			toolName: 'post',
			input: { endpoint: 'https://api/x', payload: 'hi' },
		} as never);
		rec.record({
			type: 'tool-result',
			toolCallId: 't1',
			toolName: 'post',
			output: { ok: true },
		} as never);

		const tc = rec.getMessageRecord().timeline.find((e) => e.type === 'tool-call')!;
		expect(tc.nodeParameters).toEqual({
			url: 'https://api/x',
			body: 'hi',
		});
	});
});

describe('ExecutionRecorder — subagent-chunk', () => {
	it('attaches forwarded child chunks to the open delegate tool-call entry', () => {
		const recorder = new ExecutionRecorder();
		recorder.record(makeToolCallChunk('delegate_subagent', { goal: 'x' }, 'tc-parent'));
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: { type: 'text-delta', id: 't-1', delta: 'hello' },
		} as StreamChunk);
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: {
				type: 'tool-input-start',
				toolCallId: 'child-tc-1',
				toolName: 'web_search',
			},
		} as StreamChunk);
		recorder.record({
			type: 'tool-execution-end',
			toolCallId: 'tc-parent',
			toolName: 'delegate_subagent',
			isError: false,
			endTime: 2_000,
		});
		recorder.record(makeToolResultChunk('delegate_subagent', { status: 'completed' }, 'tc-parent'));

		const entry = recorder
			.getMessageRecord()
			.timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-parent');
		expect(entry).toMatchObject({
			childTrace: {
				text: 'hello',
				steps: [{ toolCallId: 'child-tc-1', toolName: 'web_search', running: false }],
			},
		});
	});

	it('caps persisted child text at 4000 characters, trimming a delta that straddles it', () => {
		const recorder = new ExecutionRecorder();
		recorder.record(makeToolCallChunk('delegate_subagent', {}, 'tc-parent'));
		// Neither delta lands on the boundary, so the second must be trimmed
		// rather than persisted whole.
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: { type: 'text-delta', id: 't-1', delta: 'a'.repeat(3_000) },
		} as StreamChunk);
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: { type: 'text-delta', id: 't-1', delta: 'b'.repeat(3_000) },
		} as StreamChunk);
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: { type: 'text-delta', id: 't-1', delta: 'over budget' },
		} as StreamChunk);
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: {
				type: 'tool-input-start',
				toolCallId: 'child-tc-1',
				toolName: 'web_search',
			},
		} as StreamChunk);
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: {
				type: 'tool-execution-end',
				toolCallId: 'child-tc-1',
				toolName: 'web_search',
				isError: false,
				endTime: 1,
			},
		} as StreamChunk);

		const entry = recorder
			.getMessageRecord()
			.timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-parent');
		expect(entry?.type).toBe('tool-call');
		if (entry?.type !== 'tool-call') return;
		expect(entry.childTrace?.text).toHaveLength(4_000);
		expect(entry.childTrace?.steps).toEqual([
			{ toolCallId: 'child-tc-1', toolName: 'web_search', running: false },
		]);
	});

	it('settles child steps when the delegation closes via tool-result alone', () => {
		const recorder = new ExecutionRecorder();
		recorder.record(makeToolCallChunk('delegate_subagent', {}, 'tc-parent'));
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'tc-parent',
			chunk: {
				type: 'tool-input-start',
				toolCallId: 'child-tc-1',
				toolName: 'web_search',
			},
		} as StreamChunk);
		// No tool-execution-end for the parent — only the batched tool-result.
		recorder.record(makeToolResultChunk('delegate_subagent', { status: 'completed' }, 'tc-parent'));

		const entry = recorder
			.getMessageRecord()
			.timeline.find((e) => e.type === 'tool-call' && e.toolCallId === 'tc-parent');
		expect(entry?.type).toBe('tool-call');
		if (entry?.type !== 'tool-call') return;
		expect(entry.childTrace?.steps).toEqual([
			{ toolCallId: 'child-tc-1', toolName: 'web_search', running: false },
		]);
	});

	it('ignores a subagent-chunk with no matching open tool-call entry', () => {
		const recorder = new ExecutionRecorder();
		recorder.record({
			type: 'subagent-chunk',
			taskName: 'research',
			taskPath: '/root/research_0',
			parentToolCallId: 'missing',
			chunk: { type: 'text-delta', id: 't-1', delta: 'orphan' },
		} as StreamChunk);

		expect(recorder.getMessageRecord().timeline).toEqual([]);
	});
});

describe('ExecutionRecorder — durable timeline events', () => {
	it('emits replacing live snapshots without splitting the final response', () => {
		vi.useFakeTimers();
		try {
			const snapshots: TimelineEvent[][] = [];
			const recorder = new ExecutionRecorder(undefined, (timeline) => snapshots.push(timeline));
			recorder.record({ type: 'text-delta', id: 'text-1', delta: 'First' });
			vi.advanceTimersByTime(999);
			expect(snapshots).toEqual([]);
			vi.advanceTimersByTime(1);
			expect(snapshots).toEqual([[expect.objectContaining({ type: 'text', content: 'First' })]]);

			recorder.record({ type: 'text-delta', id: 'text-1', delta: ' second' });
			vi.advanceTimersByTime(1_000);

			expect(snapshots).toEqual([
				[expect.objectContaining({ type: 'text', content: 'First' })],
				[expect.objectContaining({ type: 'text', content: 'First second' })],
			]);
			const record = recorder.getMessageRecord();
			expect(record.assistantResponse).toBe('First second');
			expect(record.timeline).toEqual([
				expect.objectContaining({ type: 'text', content: 'First second' }),
			]);
		} finally {
			vi.useRealTimers();
		}
	});

	it('emits a tool snapshot when execution starts and updates it on completion', () => {
		const snapshots: TimelineEvent[][] = [];
		const recorder = new ExecutionRecorder(undefined, (timeline) => snapshots.push(timeline));

		recorder.record({ type: 'text-delta', id: 'text-1', delta: 'Checking' });
		expect(snapshots).toEqual([]);

		recorder.record({
			type: 'tool-call',
			toolCallId: 'tool-1',
			toolName: 'lookup',
			input: { id: 1 },
		} as StreamChunk);
		expect(snapshots).toEqual([[expect.objectContaining({ type: 'text', content: 'Checking' })]]);

		recorder.record({
			type: 'tool-execution-start',
			toolCallId: 'tool-1',
			toolName: 'lookup',
			startTime: 10,
		});
		expect(snapshots[1]?.[1]).toMatchObject({
			type: 'tool-call',
			startTime: 10,
			endTime: 0,
		});

		recorder.record({
			type: 'tool-execution-end',
			toolCallId: 'tool-1',
			toolName: 'lookup',
			isError: false,
			endTime: 20,
		});
		recorder.record({
			type: 'tool-result',
			toolCallId: 'tool-1',
			toolName: 'lookup',
			output: { name: 'Ada' },
		});

		expect(snapshots).toHaveLength(4);
		expect(snapshots[2]?.[1]).toMatchObject({
			type: 'tool-call',
			endTime: 20,
			output: undefined,
		});
		expect(snapshots[3]?.[1]).toMatchObject({
			type: 'tool-call',
			endTime: 20,
			output: { name: 'Ada' },
		});
	});
});
