import { ExecutionRecorder } from '../execution-recorder';
import type { BuiltTool, StreamChunk } from '@n8n/agents';
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', () => {
	describe('per-tool execution timing', () => {
		afterEach(() => {
			jest.useRealTimers();
		});

		it('records distinct per-tool end times from tool-execution-end for concurrent tools', () => {
			jest.useFakeTimers();
			jest.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.
			jest.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', () => {
			jest.useFakeTimers();
			jest.setSystemTime(1_000);
			const recorder = new ExecutionRecorder();

			recorder.record(makeToolCallChunk('a', {}, 'tc-1'));
			jest.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', () => {
		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 suspension as a timeline 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',
			} as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(recorder.suspended).toBe(true);
			expect(record.timeline.some((e) => e.type === 'suspension')).toBe(true);
		});
	});

	describe('backward compat', () => {
		it('still populates flat toolCalls array', () => {
			const recorder = new ExecutionRecorder();

			recorder.record(makeToolCallChunk('my_tool', { x: 1 }));
			recorder.record(makeToolResultChunk('my_tool', { y: 2 }));
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(record.toolCalls).toHaveLength(1);
			expect(record.toolCalls[0]).toEqual({
				name: 'my_tool',
				input: { x: 1 },
				output: { y: 2 },
			});
		});

		it('pairs same-name flat tool calls by toolCallId when results arrive out of order', () => {
			const recorder = new ExecutionRecorder();

			recorder.record(makeToolCallChunk('same_name_tool', { file: 'first' }, 'call-1'));
			recorder.record(makeToolCallChunk('same_name_tool', { file: 'second' }, 'call-2'));
			recorder.record(makeToolResultChunk('same_name_tool', { result: 'second' }, 'call-2'));
			recorder.record(makeToolResultChunk('same_name_tool', { result: 'first' }, 'call-1'));
			recorder.record({ type: 'finish', finishReason: 'stop' } as StreamChunk);

			const record = recorder.getMessageRecord();

			expect(record.toolCalls).toEqual([
				{
					name: 'same_name_tool',
					input: { file: 'first' },
					output: { result: 'first' },
				},
				{
					name: 'same_name_tool',
					input: { file: 'second' },
					output: { result: 'second' },
				},
			]);
		});

		it('still 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 flat records and 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(record.toolCalls[0]).toEqual({
				name: 'lookup',
				input: {
					query: 'project status',
					password: '[REDACTED]',
					nested: { apiKey: '[REDACTED]' },
				},
				output: {
					result: '[REDACTED]',
					authorization: '[REDACTED]',
				},
			});
			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();

			expect(record.toolCalls[0].output).toEqual({ 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);
		expect(record.toolCalls).toHaveLength(1);
		expect(record.toolCalls[0]).toEqual({
			name: 'run-wf',
			input: undefined,
			output: { executionId: 'e-99', status: 'success' },
		});
	});

	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',
		});
	});
});
