// ---------------------------------------------------------------------------
// Tests for UserProxyLlm — structured-output dispatch with deterministic shortcuts.
//
// The proxy delegates LLM-driven decisions to an injectable agent
// (UserProxyAgent). Tests pass a programmable fake agent to assert routing,
// deterministic shortcuts, repeat detection, and budget enforcement.
// ---------------------------------------------------------------------------

import type { InstanceAiCredentialSetupHint } from '@n8n/api-types';

import type { N8nClient } from '../clients/n8n-client';
import { createOneCredential } from '../credentials/seeder';
import type { EvalLogger } from '../harness/logger';
import type { CapturedEvent } from '../types';
import { UserProxyLlm } from '../utils/user-proxy';
import type { UserProxyAgent } from '../utils/user-proxy/agent';
import {
	confirmationDecisionSchema,
	userTurnDecisionSchema,
	type Decision,
	type ProxyDecisionMode,
} from '../utils/user-proxy/tools';

// Spies on the real seeder so existing credential-creation tests (which assert
// against `client.createCredential`) keep working unchanged, while the new
// setupHint tests below can assert on what reaches `createOneCredential`
// itself. The mock wraps the real implementation, so tests execute the full
// minting path (including the seeder's throw when `httpTemplatedCustomAuth`
// is reached without a valid hint).
vi.mock('../credentials/seeder', async (importOriginal) => {
	const actual = await importOriginal<typeof import('../credentials/seeder')>();
	return { ...actual, createOneCredential: vi.fn(actual.createOneCredential) };
});

/** Returns a fresh fake each call — tests assert on individual `vi.fn()` call
 *  counts, so a single shared instance would leak state across tests. */
function fakeLogger(): EvalLogger {
	return {
		warn: vi.fn(),
		info: vi.fn(),
		verbose: vi.fn(),
		success: vi.fn(),
		error: vi.fn(),
		isVerbose: false,
	};
}

/** Minimal fake satisfying only the two N8nClient methods credential creation uses. */
function fakeCredentialClient(
	createdId: string,
	...furtherCreatedIds: string[]
): {
	client: N8nClient;
	createCredential: ReturnType<typeof vi.fn>;
	setThreadCredentialAllowlist: ReturnType<typeof vi.fn>;
} {
	// Extra ids are handed out per call, for a card that creates more than one.
	const createCredential = furtherCreatedIds.reduce(
		(mock, id) => mock.mockResolvedValueOnce({ id }),
		vi.fn().mockResolvedValueOnce({ id: createdId }),
	);
	createCredential.mockResolvedValue({ id: createdId });
	const setThreadCredentialAllowlist = vi.fn().mockResolvedValue(undefined);
	return {
		client: { createCredential, setThreadCredentialAllowlist } as unknown as N8nClient,
		createCredential,
		setThreadCredentialAllowlist,
	};
}

// ---------------------------------------------------------------------------
// FakeAgent — programmable agent for tests
// ---------------------------------------------------------------------------

class FakeAgent implements UserProxyAgent {
	readonly prompts: string[] = [];
	readonly modes: ProxyDecisionMode[] = [];
	private queue: Array<Decision | undefined | Error> = [];

	enqueue(...decisions: Array<Decision | undefined | Error>): void {
		this.queue.push(...decisions);
	}

	// eslint-disable-next-line @typescript-eslint/require-await
	async decide(userPrompt: string, mode: ProxyDecisionMode): Promise<Decision | undefined> {
		this.prompts.push(userPrompt);
		this.modes.push(mode);
		const next = this.queue.shift();
		if (next instanceof Error) throw next;
		return next;
	}

	get callCount(): number {
		return this.prompts.length;
	}
}

// ---------------------------------------------------------------------------
// Event helpers
// ---------------------------------------------------------------------------

function questionEvent(
	requestId: string,
	questions: Array<{
		id: string;
		question: string;
		type: 'single' | 'multi' | 'text';
		options?: string[];
	}>,
): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'ask-user',
				args: {},
				severity: 'info',
				message: 'Please answer',
				inputType: 'questions',
				questions,
			},
		},
	};
}

function planReviewEvent(requestId: string): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'create-tasks',
				args: {},
				severity: 'info',
				message: 'Approve plan?',
				inputType: 'plan-review',
			},
		},
	};
}

function setupWizardEvent(
	requestId: string,
	setupRequests: Array<Record<string, unknown>> = [
		{
			nodeId: 'n1',
			nodeName: 'Send Slack Message',
			editableParameters: [{ name: 'channelId' }],
		},
	],
): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'setup-workflow',
				args: {},
				severity: 'info',
				message: 'Set up the workflow',
				setupRequests,
			},
		},
	};
}

function credentialEvent(requestId: string): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'credential-setup',
				args: {},
				severity: 'info',
				message: 'Set up credentials',
				credentialRequests: [{ type: 'slackApi' }],
			},
		},
	};
}

/** Real `credentials.tool.ts` shape (`credentialType` + `existingCredentials`),
 *  needed once the payload is actually parsed for `choose_credential_setup_option`. */
function credentialEventWithRequests(
	requestId: string,
	requests: Array<{
		credentialType: string;
		existingCredentials?: Array<{ id: string; name: string }>;
	}>,
): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'credential-setup',
				args: {},
				severity: 'info',
				message: 'Set up credentials',
				credentialRequests: requests.map((r) => ({
					credentialType: r.credentialType,
					existingCredentials: r.existingCredentials ?? [],
				})),
			},
		},
	};
}

function domainAccessEvent(requestId: string): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'web-research',
				args: {},
				severity: 'info',
				message: 'Allow domain?',
				domainAccess: { url: 'https://docs.example.com', host: 'docs.example.com' },
			},
		},
	};
}

function webSearchEvent(requestId: string): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-y',
				toolName: 'research',
				args: {},
				severity: 'info',
				message: 'n8n AI wants to search the web for: stripe webhook signing',
				webSearch: { query: 'stripe webhook signing' },
			},
		},
	};
}

function resourceDecisionEvent(requestId: string, options: string[]): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'gateway-resource',
				args: {},
				severity: 'info',
				message: 'Pick option',
				resourceDecision: { options },
			},
		},
	};
}

function textInputEvent(requestId: string): CapturedEvent {
	return {
		timestamp: 100,
		type: 'confirmation-request',
		data: {
			type: 'confirmation-request',
			payload: {
				requestId,
				toolCallId: 'tc-x',
				toolName: 'pause-for-user',
				args: {},
				severity: 'info',
				message: 'Please respond',
				inputType: 'text',
			},
		},
	};
}

// ---------------------------------------------------------------------------
// respondToConfirmation
// ---------------------------------------------------------------------------

describe('UserProxyLlm.respondToConfirmation', () => {
	it('answers questions when the agent returns answer_questions', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'answer_questions',
			answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post to #general' }],
			agent,
		});

		const event = questionEvent('req-1', [
			{ id: 'q1', question: 'Which channel?', type: 'single', options: ['#general'] },
		]);
		const response = await proxy.respondToConfirmation(event);

		expect(response.kind).toBe('questions');
		if (response.kind === 'questions') {
			expect(response.answers).toEqual([{ questionId: 'q1', selectedOptions: ['#general'] }]);
		}
		expect(agent.callCount).toBe(1);
		expect(agent.modes[0]).toBe('confirmation');
	});

	it('routes ask-user questions to the agent even when scripted user turns remain (no deterministic shortcut)', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'answer_questions',
			answers: [
				{ questionId: 'cities', selectedOptions: [], customText: 'London, New York, Tokyo' },
				{ questionId: 'destination', selectedOptions: ['Slack'] },
			],
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'I need weather alerts.' },
				{ role: 'assistant', text: 'Which cities and where should alerts go?' },
				{
					role: 'user',
					text: 'London, New York, Tokyo. Alert above 30C via Telegram chat -1001234567890.',
				},
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			questionEvent('req-scripted-q', [
				{ id: 'cities', question: 'Which cities?', type: 'text' },
				{
					id: 'destination',
					question: 'Where should alerts go?',
					type: 'single',
					options: ['Email', 'Slack', 'SMS'],
				},
			]),
		);

		expect(agent.callCount).toBe(1);
		expect(response.kind).toBe('questions');
		if (response.kind === 'questions') {
			expect(response.answers).toEqual([
				{ questionId: 'cities', selectedOptions: [], customText: 'London, New York, Tokyo' },
				{ questionId: 'destination', selectedOptions: ['Slack'] },
			]);
		}
	});

	it('returns approval with userInput when the agent picks approve_or_reject', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'approve_or_reject',
			approved: true,
			userInput: 'looks good',
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'approve' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-pr'));
		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(true);
			expect(response.userInput).toBe('looks good');
		}
	});

	it('rejects plan review with remaining scripted details before consulting the agent', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Build an Airtable to Slack workflow.' },
				{ role: 'assistant', text: 'Which table and channel?' },
				{
					role: 'user',
					text: 'Use GET https://api.airtable.com/v0/app123abc/Tasks and Slack #daily-tasks.',
				},
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-scripted-plan'));

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
			expect(response.userInput).toContain('Before I approve');
			expect(response.userInput).toContain('https://api.airtable.com/v0/app123abc/Tasks');
			expect(response.userInput).toContain('#daily-tasks');
		}
		expect(agent.callCount).toBe(0);
	});

	it('returns approval with no userInput when the agent omits it', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'approve_or_reject', approved: true });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'approve' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-pr'));
		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(true);
			expect(response.userInput).toBeUndefined();
		}
	});

	it('rejects a plan when the agent returns approve_or_reject with approved=false', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'approve_or_reject',
			approved: false,
			userInput: 'I wanted email, not data table',
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'send an email' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-pr'));
		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
			expect(response.userInput).toContain('email');
		}
	});

	it('encodes apply_setup_wizard into setupWorkflowApply with nodeParameters', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({
				'Send Slack Message': { channelId: 'general', text: 'hi' },
			}),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post hi to #general' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(setupWizardEvent('req-sw'));
		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({
				'Send Slack Message': { channelId: 'general', text: 'hi' },
			});
			expect(response.nodeCredentials).toBeUndefined();
		}
	});

	it('normalizes a single setup node parameter map into nodeParameters', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({
				channelId: { __rl: true, mode: 'name', value: '#berlin-weather-rain' },
			}),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post rain alerts to #berlin-weather-rain' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw', [
				{
					nodeId: 'slack-rain',
					nodeName: 'Send Rain Alert',
					editableParameters: [{ name: 'channelId' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({
				'Send Rain Alert': {
					channelId: { __rl: true, mode: 'name', value: '#berlin-weather-rain' },
				},
			});
		}
	});

	it('maps setup node id keys to setup node names', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({
				'slack-rain': { channelId: '#berlin-weather-rain' },
			}),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post rain alerts to #berlin-weather-rain' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw', [
				{
					nodeId: 'slack-rain',
					nodeName: 'Send Rain Alert',
					editableParameters: [{ name: 'channelId' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({
				'Send Rain Alert': { channelId: '#berlin-weather-rain' },
			});
		}
	});

	it('rejects mixed valid and unknown setup node keys', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({
				'Send Rain Alert': { channelId: '#berlin-weather-rain' },
				UnknownNode: { channelId: '#other-channel' },
			}),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post rain alerts to #berlin-weather-rain' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw', [
				{
					nodeId: 'slack-rain',
					nodeName: 'Send Rain Alert',
					editableParameters: [{ name: 'channelId' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({});
		}
	});

	it('rejects setup parameters that do not match the setup card', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({ __rl: true, mode: 'name' }),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'post rain alerts to #berlin-weather-rain' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw', [
				{
					nodeId: 'slack-rain',
					nodeName: 'Send Rain Alert',
					editableParameters: [{ name: 'channelId' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({});
		}
	});

	// -------------------------------------------------------------------------
	// TRUST-349 — workflows(action='setup') wizard: credential slots via
	// apply_setup_wizard's nodeCredentialsJson. This is the tool the builder
	// actually reaches for during a normal build ("the setup card" a real user
	// sees); NOT the standalone credentials(action='setup') tool below.
	// -------------------------------------------------------------------------

	it("workflows(action='setup'): fills both parameters and a credential slot on a mixed wizard card when engaged", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({ 'Post Standup Reminder': { channelId: 'general' } }),
			nodeCredentialsJson: JSON.stringify({ 'Post Standup Reminder': { slackApi: 'cred-team' } }),
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post a standup reminder to Slack every morning.' },
				{
					role: 'user',
					text: '[Set up the Slack credential now, using Team Slack, on the setup card.]',
				},
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-mixed', [
				{
					nodeId: 'n1',
					nodeName: 'Post Standup Reminder',
					editableParameters: [{ name: 'channelId' }],
				},
				{
					nodeId: 'n1',
					nodeName: 'Post Standup Reminder',
					credentialType: 'slackApi',
					existingCredentials: [
						{ id: 'cred-personal', name: 'Personal Slack' },
						{ id: 'cred-team', name: 'Team Slack' },
					],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeParameters).toEqual({
				'Post Standup Reminder': { channelId: 'general' },
			});
			expect(response.nodeCredentials).toEqual({
				'Post Standup Reminder': { slackApi: 'cred-team' },
			});
		}
	});

	it("workflows(action='setup'): routes a credential-only wizard card to the agent when engaged, instead of auto-declining", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'cred-team' } }),
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now, using Team Slack.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-cred-only', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [{ id: 'cred-team', name: 'Team Slack' }],
				},
			]),
		);

		expect(agent.callCount).toBe(1);
		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toEqual({ 'Post To Slack': { slackApi: 'cred-team' } });
		}
	});

	it("workflows(action='setup'): still auto-declines a credential-only wizard card with no governing stage direction", async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'Post to Slack every morning.' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-cred-only-default', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [{ id: 'cred-team', name: 'Team Slack' }],
				},
			]),
		);

		expect(agent.callCount).toBe(0);
		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
	});

	it("workflows(action='setup'): maps different credential types for two different nodes on the same wizard card", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({
				'Get Notion Pages': { notionApi: 'cred-notion' },
				'Post To Slack': { slackApi: 'cred-team' },
			}),
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Summarize Notion pages to Slack.' },
				{ role: 'user', text: '[Set up both the Notion and Slack credentials now.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-two-nodes', [
				{
					nodeId: 'n1',
					nodeName: 'Get Notion Pages',
					credentialType: 'notionApi',
					existingCredentials: [{ id: 'cred-notion', name: 'Notion' }],
				},
				{
					nodeId: 'n2',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [{ id: 'cred-team', name: 'Team Slack' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toEqual({
				'Get Notion Pages': { notionApi: 'cred-notion' },
				'Post To Slack': { slackApi: 'cred-team' },
			});
		}
	});

	it("workflows(action='setup'): drops a nodeCredentialsJson entry naming a node not on the setup card", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Unknown Node': { slackApi: 'cred-team' } }),
		});
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-unknown-node', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [{ id: 'cred-team', name: 'Team Slack' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toBeUndefined();
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("workflows(action='setup'): auto-accepts the sole existing credential regardless of the id string given, when there's only one candidate", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			// The model isn't required to echo the real id back correctly when
			// there's only one candidate — TRUST-349's folded manual behavior
			// auto-selects the sole existing credential regardless.
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'whatever' } }),
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-single-any-id', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [{ id: 'cred-team', name: 'Team Slack' }],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toEqual({ 'Post To Slack': { slackApi: 'cred-team' } });
		}
	});

	it("workflows(action='setup'): drops a nodeCredentialsJson entry naming a credential id that matches none of several existing candidates", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'cred-bogus' } }),
		});
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-bogus-id-ambiguous', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [
						{ id: 'cred-personal', name: 'Personal Slack' },
						{ id: 'cred-team', name: 'Team Slack' },
					],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toBeUndefined();
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("workflows(action='setup'): creates a real credential when the resolved slot has zero existing candidates", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
		});
		const { client, createCredential, setThreadCredentialAllowlist } =
			fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: ['cred-old'] },
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-create', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		expect(createCredential).toHaveBeenCalledWith(
			expect.any(String),
			'slackApi',
			expect.any(Object),
		);
		// The allowlist call must include the pre-existing id, not just the new
		// one — setThreadCredentialAllowlist replaces the whole list.
		expect(setThreadCredentialAllowlist).toHaveBeenCalledWith(
			'thread-1',
			['cred-old', 'cred-fresh'],
			[],
		);
		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toEqual({ 'Post To Slack': { slackApi: 'cred-fresh' } });
		}
	});

	it("workflows(action='setup'): registers a mid-run-created credential id for cleanup when configured", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
		});
		const { client } = fakeCredentialClient('cred-fresh');
		const createdCredentialIds = new Set<string>();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			credentialCreation: {
				client,
				threadId: 'thread-1',
				allowlistedCredentialIds: [],
				createdCredentialIds,
			},
		});

		await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-create-cleanup', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		expect(createdCredentialIds.has('cred-fresh')).toBe(true);
	});

	it("workflows(action='setup'): registers the created credential for test bypass when the direction says it works", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
			workingCredentialTypes: ['slackApi'],
		});
		const { client, setThreadCredentialAllowlist } = fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now, with a token that works.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: ['cred-old'] },
		});

		await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-works', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		// The bypass must be registered in the SAME call that appends the new id, so
		// it lands before the product runs the credential test on the resume.
		expect(setThreadCredentialAllowlist).toHaveBeenCalledWith(
			'thread-1',
			['cred-old', 'cred-fresh'],
			['cred-fresh'],
		);
		expect(proxy.getDecisionStats()['credential-test-bypassed']).toBe(1);
	});

	it("workflows(action='setup'): bypasses only the credential types the direction says work, on a two-credential card", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({
				'Post To Slack': { slackApi: 'new' },
				'Fetch Notion Pages': { notionApi: 'new' },
			}),
			// Slack works, Notion doesn't — the whole point of a per-type list.
			workingCredentialTypes: ['slackApi'],
		});
		const { client, setThreadCredentialAllowlist } = fakeCredentialClient(
			'cred-first',
			'cred-second',
		);
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Summarize Notion pages to Slack every morning.' },
				{
					role: 'user',
					text: '[Set both credentials up now: the Slack token works, the Notion one is expired.]',
				},
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: [] },
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-mixed-validity', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
				{
					nodeId: 'n2',
					nodeName: 'Fetch Notion Pages',
					credentialType: 'notionApi',
					existingCredentials: [],
				},
			]),
		);

		// Assert against the ids the response actually assigned rather than the order
		// the mock handed them out: the Slack slot's credential is registered for
		// bypass and the Notion slot's is not, whichever was created first.
		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind !== 'setupWorkflowApply') return;
		const slackId = response.nodeCredentials?.['Post To Slack']?.slackApi;
		const notionId = response.nodeCredentials?.['Fetch Notion Pages']?.notionApi;
		expect(slackId).toBeDefined();
		expect(notionId).toBeDefined();
		expect(setThreadCredentialAllowlist.mock.calls.at(-1)?.[2]).toEqual([slackId]);
		expect(proxy.getDecisionStats()['credential-test-bypassed']).toBe(1);
	});

	it("workflows(action='setup'): leaves the credential test alone when the direction says nothing about validity", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
		});
		const { client, setThreadCredentialAllowlist } = fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: [] },
		});

		await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-no-works', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		// An empty bypass list, which the client drops from the request body.
		expect(setThreadCredentialAllowlist).toHaveBeenCalledWith('thread-1', ['cred-fresh'], []);
		expect(proxy.getDecisionStats()['credential-test-bypassed']).toBeUndefined();
	});

	it("workflows(action='setup'): keeps the seeded credentials bypassed when it creates another", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
			workingCredentialTypes: ['slackApi'],
		});
		const { client, setThreadCredentialAllowlist } = fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now; the token works.]' },
			],
			agent,
			credentialCreation: {
				client,
				threadId: 'thread-1',
				allowlistedCredentialIds: ['cred-seeded'],
				bypassCredentialTestIds: ['cred-seeded'],
			},
		});

		await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-seeded-bypass', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		// The endpoint replaces both lists, so the seeded id has to be re-sent —
		// dropping it would silently un-bypass the case's declared credentials.
		expect(setThreadCredentialAllowlist).toHaveBeenCalledWith(
			'thread-1',
			['cred-seeded', 'cred-fresh'],
			['cred-seeded', 'cred-fresh'],
		);
	});

	it("workflows(action='setup'): declines a zero-candidate credential slot when no credentialCreation is configured", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
		});
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-create-unwired', [
				{
					nodeId: 'n1',
					nodeName: 'Post To Slack',
					credentialType: 'slackApi',
					existingCredentials: [],
				},
			]),
		);

		expect(response.kind).toBe('setupWorkflowApply');
		if (response.kind === 'setupWorkflowApply') {
			expect(response.nodeCredentials).toBeUndefined();
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("workflows(action='setup'): threads a valid setupHint through to credential creation for httpTemplatedCustomAuth", async () => {
		const setupHint: InstanceAiCredentialSetupHint = {
			template: { headers: { Authorization: '{{apiKey}}' } },
			placeholders: [{ name: 'apiKey', title: 'API Key', optional: false }],
		};
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Call API': { httpTemplatedCustomAuth: 'new' } }),
		});
		const { client } = fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Call the API every morning.' },
				{ role: 'user', text: '[Set up the API credential now.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: [] },
		});

		await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-templated-auth', [
				{
					nodeId: 'n1',
					nodeName: 'Call API',
					credentialType: 'httpTemplatedCustomAuth',
					existingCredentials: [],
					setupHint,
				},
			]),
		);

		expect(createOneCredential).toHaveBeenCalledWith(
			client,
			'httpTemplatedCustomAuth',
			undefined,
			expect.anything(),
			expect.objectContaining({ setupHint }),
		);
	});

	it("workflows(action='setup'): drops a malformed setupHint instead of forwarding a garbage partial object", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: '{}',
			nodeCredentialsJson: JSON.stringify({ 'Call API': { httpTemplatedCustomAuth: 'new' } }),
		});
		const { client } = fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Call the API every morning.' },
				{ role: 'user', text: '[Set up the API credential now.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: [] },
		});

		const response = await proxy.respondToConfirmation(
			setupWizardEvent('req-sw-templated-auth-malformed', [
				{
					nodeId: 'n1',
					nodeName: 'Call API',
					credentialType: 'httpTemplatedCustomAuth',
					existingCredentials: [],
					// Missing the required `placeholders` field.
					setupHint: { template: { headers: { Authorization: '{{apiKey}}' } } },
				},
			]),
		);

		expect(response.kind).toBeDefined();
		expect(createOneCredential).toHaveBeenCalledWith(
			client,
			'httpTemplatedCustomAuth',
			undefined,
			expect.anything(),
			expect.objectContaining({ setupHint: undefined }),
		);
	});

	it("credentials(action='setup'): handles credential events deterministically without invoking the agent", async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(credentialEvent('req-cred'));
		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({});
		}
		expect(agent.callCount).toBe(0);
	});

	// -------------------------------------------------------------------------
	// TRUST-349 — credentials(action='setup') standalone tool: choose_credential_setup_option.
	// This is the "auto | manual | skip" card — NOT the workflows(action='setup')
	// wizard above. Live testing (see PR description) found the builder doesn't
	// actually reach for this tool during a normal build; kept per explicit
	// decision to retain it in case some other flow (OAuth-specific, or a
	// standalone "connect my X account" request) triggers it.
	// -------------------------------------------------------------------------

	it("credentials(action='setup'): routes to the agent when a stage direction asks the user to engage", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'manual' });
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{
					role: 'user',
					text: '[When the credential setup card for Slack appears, set up the credential now using the existing Slack credential shown on the card.]',
				},
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-manual', [
				{ credentialType: 'slackApi', existingCredentials: [{ id: 'cred-1', name: 'My Slack' }] },
			]),
		);

		expect(agent.callCount).toBe(1);
		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({ slackApi: 'cred-1' });
		}
	});

	it("credentials(action='setup'): resolves manual selection by explicit credentialType among multiple requests", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'choose_credential_setup_option',
			option: 'manual',
			credentialType: 'notionApi',
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Summarize Notion pages to Slack.' },
				{ role: 'user', text: '[Connect the Notion credential shown on the card.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-multi', [
				{ credentialType: 'slackApi', existingCredentials: [{ id: 'cred-slack', name: 'Slack' }] },
				{
					credentialType: 'notionApi',
					existingCredentials: [{ id: 'cred-notion', name: 'Notion' }],
				},
			]),
		);

		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({ notionApi: 'cred-notion' });
		}
	});

	it("credentials(action='setup'): resolves manual selection to a specific credential by existingCredentialId when several match the same type", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'choose_credential_setup_option',
			option: 'manual',
			existingCredentialId: 'cred-team',
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the credential now, using the Team Slack one.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-disambiguate', [
				{
					credentialType: 'slackApi',
					existingCredentials: [
						{ id: 'cred-personal', name: 'Personal Slack' },
						{ id: 'cred-team', name: 'Team Slack' },
					],
				},
			]),
		);

		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({ slackApi: 'cred-team' });
		}
	});

	it("credentials(action='setup'): declines manual selection when existingCredentialId does not match any listed credential", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'choose_credential_setup_option',
			option: 'manual',
			existingCredentialId: 'cred-does-not-exist',
		});
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-bad-id', [
				{
					credentialType: 'slackApi',
					existingCredentials: [
						{ id: 'cred-personal', name: 'Personal Slack' },
						{ id: 'cred-team', name: 'Team Slack' },
					],
				},
			]),
		);

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("credentials(action='setup'): declines manual selection when several candidates exist and no existingCredentialId disambiguates", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'manual' });
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-ambiguous', [
				{
					credentialType: 'slackApi',
					existingCredentials: [
						{ id: 'cred-personal', name: 'Personal Slack' },
						{ id: 'cred-team', name: 'Team Slack' },
					],
				},
			]),
		);

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("credentials(action='setup'): declines manual selection when the requested type has no existing credential and no credentialCreation is configured", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'manual' });
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-none', [{ credentialType: 'slackApi' }]),
		);

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("credentials(action='setup'): manual creates a real credential when the requested type has zero existing candidates", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'manual' });
		const { client, createCredential, setThreadCredentialAllowlist } =
			fakeCredentialClient('cred-fresh');
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Set up the Slack credential now.]' },
			],
			agent,
			credentialCreation: { client, threadId: 'thread-1', allowlistedCredentialIds: [] },
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-create', [{ credentialType: 'slackApi' }]),
		);

		expect(createCredential).toHaveBeenCalledWith(
			expect.any(String),
			'slackApi',
			expect.any(Object),
		);
		expect(setThreadCredentialAllowlist).toHaveBeenCalledWith('thread-1', ['cred-fresh'], []);
		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({ slackApi: 'cred-fresh' });
		}
	});

	it("credentials(action='setup'): requests automatic setup when the agent picks auto", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'choose_credential_setup_option',
			option: 'auto',
			credentialType: 'slackApi',
		});
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Ask for automatic setup of the Slack credential on the card.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-auto', [
				{ credentialType: 'slackApi', existingCredentials: [{ id: 'cred-1', name: 'My Slack' }] },
			]),
		);

		expect(response.kind).toBe('credentialAutoSetup');
		if (response.kind === 'credentialAutoSetup') {
			expect(response.credentialType).toBe('slackApi');
		}
	});

	it("credentials(action='setup'): declines auto setup when no credentialType can be resolved from context or the decision", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'auto' });
		const logger = fakeLogger();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Summarize Notion pages to Slack.' },
				{ role: 'user', text: '[Ask for automatic setup of the credential on the card.]' },
			],
			agent,
			logger,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-auto-ambiguous', [
				{ credentialType: 'slackApi', existingCredentials: [{ id: 'cred-slack', name: 'Slack' }] },
				{
					credentialType: 'notionApi',
					existingCredentials: [{ id: 'cred-notion', name: 'Notion' }],
				},
			]),
		);

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
		expect(logger.warn).toHaveBeenCalled();
	});

	it("credentials(action='setup'): declines when the agent picks skip", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'skip' });
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				{ role: 'user', text: '[Explicitly decline the credential setup card for Slack.]' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			credentialEventWithRequests('req-cred-skip', [
				{ credentialType: 'slackApi', existingCredentials: [{ id: 'cred-1', name: 'My Slack' }] },
			]),
		);

		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.approved).toBe(false);
		}
	});

	// -------------------------------------------------------------------------
	// TRUST-349 PR review: the engagement gate was originally a keyword-scoped
	// regex (CREDENTIAL_ENGAGEMENT_PATTERN), rejected after a corpus audit found
	// it both misfires (matches "API key"/"credential" inside a note that
	// explicitly declines engagement) and can't be trusted to infer intent from
	// keyword presence alone. Replaced with the same content-agnostic
	// "any pending stage direction" check domain access and plan review already
	// use (`hasPendingStageDirection`) — the model, not a regex, now decides
	// whether a pending note means "engage" or "decline".
	// -------------------------------------------------------------------------

	it("credentials(action='setup'): routes to the agent whenever any stage direction is pending, regardless of content", async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'choose_credential_setup_option', option: 'skip' });
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Post to Slack every morning.' },
				// No credential/OAuth/connect vocabulary at all — the old
				// keyword-scoped gate would have left this deterministic.
				{ role: 'user', text: '[Reject the plan unless it sorts descending by count.]' },
			],
			agent,
		});

		await proxy.respondToConfirmation(credentialEvent('req-any-pending-direction'));
		expect(agent.callCount).toBe(1);
	});

	it("credentials(action='setup'): stays fully deterministic when no stage direction is pending", async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'Post to Slack every morning.' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(credentialEvent('req-no-pending-direction'));
		expect(response.kind).toBe('credentialSelection');
		if (response.kind === 'credentialSelection') {
			expect(response.credentials).toEqual({});
		}
		expect(agent.callCount).toBe(0);
	});

	it.each([
		"[Stay impatient and hands-off for the whole conversation. If the agent asks you to choose or specify any detail — where to store the orders, the schema, field mappings, which service — don't engage with the specifics.]",
		"[If the agent asks for the API key value: don't provide it — say you'll fill it into the credential yourself later. Approve plans/confirmations otherwise.]",
	])(
		"credentials(action='setup'): defers correctly on a real, unrelated-or-declining stage direction (%j) — the model is consulted (content-agnostic gate) but still ends up deferred",
		async (note) => {
			const agent = new FakeAgent();
			agent.enqueue({ action: 'choose_credential_setup_option', option: 'skip' });
			const proxy = new UserProxyLlm({
				conversation: [
					{ role: 'user', text: 'Post to Slack every morning.' },
					{ role: 'user', text: note },
				],
				agent,
			});

			const response = await proxy.respondToConfirmation(credentialEvent('req-defers-correctly'));
			// The gate no longer filters on wording, so the model is consulted...
			expect(agent.callCount).toBe(1);
			// ...but correctly reads intent and defers, same end result the old
			// keyword-scoped gate aimed for without the false-positive risk.
			expect(response.kind).toBe('approval');
			if (response.kind === 'approval') {
				expect(response.approved).toBe(false);
			}
		},
	);

	it('handles domain-access events deterministically with allow_all', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(domainAccessEvent('req-dom'));
		expect(response.kind).toBe('domainAccessApprove');
		if (response.kind === 'domainAccessApprove') {
			expect(response.domainAccessAction).toBe('allow_all');
		}
		expect(agent.callCount).toBe(0);
	});

	it('handles web-search events deterministically with allow_all', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(webSearchEvent('req-search'));
		expect(response.kind).toBe('domainAccessApprove');
		if (response.kind === 'domainAccessApprove') {
			expect(response.domainAccessAction).toBe('allow_all');
		}
		expect(agent.callCount).toBe(0);
	});

	it('defers an access gate to the LLM while a stage direction is pending, so a case can deny', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'respond_to_domain_access', response: 'deny' });
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'go' },
				{
					role: 'user',
					text: '[Refuse the web-search request — the user does not want it searching.]',
				},
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(webSearchEvent('req-deny'));

		expect(agent.callCount).toBe(1);
		expect(response.kind).toBe('domainAccessDeny');
	});

	it('still grants an access gate deterministically when the pending script has no stage direction', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'go' },
				{ role: 'user', text: 'also add a retry' },
			],
			agent,
		});

		const response = await proxy.respondToConfirmation(webSearchEvent('req-allow'));

		expect(response.kind).toBe('domainAccessApprove');
		expect(agent.callCount).toBe(0);
	});

	it('handles resource-decision events deterministically with first allow option', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(
			resourceDecisionEvent('req-res', ['deny', 'allowOnce', 'allowAll']),
		);
		expect(response.kind).toBe('resourceDecision');
		if (response.kind === 'resourceDecision') {
			expect(response.resourceDecision).toBe('allowOnce');
		}
		expect(agent.callCount).toBe(0);
	});

	it("workflows(action='setup'): routes to the agent even when the payload also includes credentialRequests (setupRequests takes priority)", async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'apply_setup_wizard',
			nodeParametersJson: JSON.stringify({ Node1: { p1: 'v1' } }),
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const event: CapturedEvent = {
			timestamp: 100,
			type: 'confirmation-request',
			data: {
				type: 'confirmation-request',
				payload: {
					requestId: 'req-mixed',
					setupRequests: [{ nodeId: 'n1', nodeName: 'Node1' }],
					credentialRequests: [{ type: 'slackApi' }],
				},
			},
		};

		const response = await proxy.respondToConfirmation(event);
		expect(response.kind).toBe('setupWorkflowApply');
		expect(agent.callCount).toBe(1);
	});

	it('falls back to the permissive payload when the agent returns undefined', async () => {
		const agent = new FakeAgent();
		agent.enqueue(undefined);
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-fail'));
		// buildAutoApprovePayload returns kind: 'approval' approved: true for plan-review
		expect(response.kind).toBe('approval');
	});

	it('falls back to the permissive payload when the agent picks a user-turn action', async () => {
		const agent = new FakeAgent();
		// declare_done is a user-turn action, invalid as a confirmation response.
		agent.enqueue({ action: 'declare_done' });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(planReviewEvent('req-mis'));
		expect(response.kind).toBe('approval');
	});

	it('reuses the first payload on a repeat requestId without consulting the agent', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'answer_questions',
			answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const event = questionEvent('req-repeat', [
			{ id: 'q1', question: 'Q?', type: 'single', options: ['#general'] },
		]);
		await proxy.respondToConfirmation(event);
		const second = await proxy.respondToConfirmation(event);

		expect(second.kind).toBe('questions');
		if (second.kind === 'questions') {
			expect(second.answers).toEqual([{ questionId: 'q1', selectedOptions: ['#general'] }]);
		}
		expect(agent.callCount).toBe(1); // only first call invoked the agent
	});

	it('does not treat a requestId as handled when decision generation throws', async () => {
		const agent = new FakeAgent();
		agent.enqueue(new Error('temporary model failure'), {
			action: 'answer_questions',
			answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});
		const event = questionEvent('req-retry', [
			{ id: 'q1', question: 'Q?', type: 'single', options: ['#general'] },
		]);

		await expect(proxy.respondToConfirmation(event)).rejects.toThrow('temporary model failure');
		const response = await proxy.respondToConfirmation(event);

		expect(response.kind).toBe('questions');
		if (response.kind === 'questions') {
			expect(response.answers).toEqual([{ questionId: 'q1', selectedOptions: ['#general'] }]);
		}
		expect(agent.callCount).toBe(2);
	});

	it('handles text input by routing to the agent and encoding as approval', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'approve_or_reject',
			approved: true,
			userInput: 'continue',
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			agent,
		});

		const response = await proxy.respondToConfirmation(textInputEvent('req-txt'));
		expect(response.kind).toBe('approval');
		if (response.kind === 'approval') {
			expect(response.userInput).toBe('continue');
		}
	});
});

// ---------------------------------------------------------------------------
// decideFollowUp
// ---------------------------------------------------------------------------

describe('UserProxyLlm.decideFollowUp', () => {
	it('returns done immediately when messageBudget is 0 without invoking the agent', async () => {
		const agent = new FakeAgent();
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'do it' }],
			messageBudget: 0,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('done');
		expect(agent.callCount).toBe(0);
	});

	it('always invokes the agent to compose the next user turn', async () => {
		// Previously the proxy short-circuited to "next script user turn
		// verbatim". The new design always defers to the agent so the message
		// can adapt to whatever the assistant just said while staying faithful
		// to the script's intent.
		const agent = new FakeAgent();
		agent.enqueue({ action: 'send_follow_up_message', message: 'also log to sheets' });
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'build the workflow' },
				{ role: 'assistant', text: 'done!' },
				{ role: 'user', text: 'now also log to sheets' },
			],
			messageBudget: 5,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('followUp');
		if (decision.kind === 'followUp') {
			expect(decision.message).toBe('also log to sheets');
		}
		expect(proxy.getMessagesSent()).toBe(1);
		expect(agent.callCount).toBe(1);
		expect(agent.modes[0]).toBe('user-turn');
	});

	it('invokes the agent on every follow-up — no verbatim shortcut for short scripts', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'send_follow_up_message', message: 'one more thing' });
		const proxy = new UserProxyLlm({
			// Only one user turn in the script.
			conversation: [{ role: 'user', text: 'build it' }],
			messageBudget: 5,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('followUp');
		if (decision.kind === 'followUp') {
			expect(decision.message).toBe('one more thing');
		}
		expect(agent.callCount).toBe(1);
	});

	it('treats declare_done as done', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'declare_done' });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'all set' }],
			messageBudget: 3,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('done');
	});

	it('returns done when the agent returns undefined', async () => {
		const agent = new FakeAgent();
		agent.enqueue(undefined);
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			messageBudget: 3,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('done');
	});

	it('falls back to the next scripted user turn when follow-up generation fails', async () => {
		const agent = new FakeAgent();
		agent.enqueue(undefined);
		const proxy = new UserProxyLlm({
			conversation: [
				{ role: 'user', text: 'Build a workflow.' },
				{ role: 'assistant', text: 'Which channel?' },
				{ role: 'user', text: 'Use #ops-alerts.' },
			],
			messageBudget: 3,
			agent,
		});

		const decision = await proxy.decideFollowUp();

		expect(decision).toEqual({ kind: 'followUp', message: 'Use #ops-alerts.' });
	});

	it('returns done when the agent picks a confirmation-only action', async () => {
		const agent = new FakeAgent();
		agent.enqueue({
			action: 'answer_questions',
			answers: [{ questionId: 'q1', selectedOptions: [] }],
		});
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			messageBudget: 3,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('done');
	});

	it('treats an empty follow-up message as done without consuming budget', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'send_follow_up_message', message: '   ' });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			messageBudget: 3,
			agent,
		});

		const decision = await proxy.decideFollowUp();
		expect(decision.kind).toBe('done');
		expect(proxy.getMessagesSent()).toBe(0);
	});

	it('caps follow-ups at messageBudget across multiple invocations', async () => {
		const agent = new FakeAgent();
		agent.enqueue(
			{ action: 'send_follow_up_message', message: 'msg1' },
			{ action: 'send_follow_up_message', message: 'msg2' },
		);
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			messageBudget: 2,
			agent,
		});

		expect((await proxy.decideFollowUp()).kind).toBe('followUp');
		expect((await proxy.decideFollowUp()).kind).toBe('followUp');
		const third = await proxy.decideFollowUp();
		expect(third.kind).toBe('done');
		expect(proxy.getMessagesSent()).toBe(2);
	});
});

// ---------------------------------------------------------------------------
// Mode-scoped decision schemas
// ---------------------------------------------------------------------------

describe('mode-scoped decision schemas', () => {
	it('user-turn schema does not offer confirmation actions', () => {
		expect(
			userTurnDecisionSchema.safeParse({
				action: 'approve_or_reject',
				approved: false,
				userInput: 'two changes first',
			}).success,
		).toBe(false);
		expect(
			userTurnDecisionSchema.safeParse({ action: 'send_follow_up_message', message: 'hi' }).success,
		).toBe(true);
		expect(userTurnDecisionSchema.safeParse({ action: 'declare_done' }).success).toBe(true);
	});

	it('confirmation schema does not offer user-turn actions', () => {
		expect(
			confirmationDecisionSchema.safeParse({ action: 'send_follow_up_message', message: 'hi' })
				.success,
		).toBe(false);
		expect(confirmationDecisionSchema.safeParse({ action: 'declare_done' }).success).toBe(false);
		expect(
			confirmationDecisionSchema.safeParse({ action: 'approve_or_reject', approved: true }).success,
		).toBe(true);
	});
});

// ---------------------------------------------------------------------------
// ingestEvents
// ---------------------------------------------------------------------------

describe('UserProxyLlm.ingestEvents', () => {
	it('accumulates text-delta payloads into the rolling transcript', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'declare_done' });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'open a ticket' }],
			messageBudget: 3,
			agent,
		});

		const events: CapturedEvent[] = [
			{ timestamp: 1, type: 'run-start', data: { type: 'run-start' } },
			{
				timestamp: 2,
				type: 'text-delta',
				data: { type: 'text-delta', payload: { text: 'Hello ' } },
			},
			{
				timestamp: 3,
				type: 'text-delta',
				data: { type: 'text-delta', payload: { text: 'world' } },
			},
			{ timestamp: 4, type: 'run-finish', data: { type: 'run-finish' } },
			{ timestamp: 5, type: 'run-start', data: { type: 'run-start' } },
			{ timestamp: 6, type: 'text-delta', data: { type: 'text-delta', text: 'second' } },
			{ timestamp: 7, type: 'run-finish', data: { type: 'run-finish' } },
		];
		proxy.ingestEvents(events);

		await proxy.decideFollowUp();
		const lastPrompt = agent.prompts[agent.prompts.length - 1];
		expect(lastPrompt).toContain('Hello world');
		expect(lastPrompt).toContain('second');
	});

	it('is idempotent — re-ingesting the same array does not duplicate transcript entries', async () => {
		const agent = new FakeAgent();
		agent.enqueue({ action: 'declare_done' });
		const proxy = new UserProxyLlm({
			conversation: [{ role: 'user', text: 'go' }],
			messageBudget: 3,
			agent,
		});

		const events: CapturedEvent[] = [
			{ timestamp: 1, type: 'run-start', data: { type: 'run-start' } },
			{
				timestamp: 2,
				type: 'text-delta',
				data: { type: 'text-delta', payload: { text: 'echoed' } },
			},
			{ timestamp: 3, type: 'run-finish', data: { type: 'run-finish' } },
		];
		proxy.ingestEvents(events);
		proxy.ingestEvents(events); // second call should be a no-op
		proxy.ingestEvents(events); // and a third

		await proxy.decideFollowUp();
		const prompt = agent.prompts[0];
		// 'echoed' should appear once in the transcript, not three times.
		expect((prompt.match(/echoed/g) ?? []).length).toBe(1);
	});
});
