feat(core): Rework builder interactive tools onto the shared interaction contract (no-changelog) (#34071)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-07-13 17:51:21 +02:00
committed by GitHub
parent 67b3ed11fe
commit 2a6119ea31
77 changed files with 3456 additions and 2326 deletions
@@ -1,5 +1,10 @@
import { z } from 'zod';
import {
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
} from './agents/agent-interaction.schema';
/**
* Canonical names of the interactive agent-builder tools.
*
@@ -8,10 +13,9 @@ import { z } from 'zod';
* by it. There is no separate `interactionType` field — the tool name IS the
* interaction kind.
*/
export const ASK_LLM_TOOL_NAME = 'ask_llm' as const;
export const ASK_CREDENTIAL_TOOL_NAME = 'ask_credential' as const;
export const ASK_EMBEDDING_CREDENTIAL_TOOL_NAME = 'ask_embedding_credential' as const;
export const ASK_QUESTION_TOOL_NAME = 'ask_question' as const;
export { ASK_QUESTIONS_TOOL_NAME, CONFIGURE_CHANNEL_TOOL_NAME };
/**
* Frontend-only discriminator for generic approval cards.
*
@@ -20,52 +24,15 @@ export const ASK_QUESTION_TOOL_NAME = 'ask_question' as const;
*/
export const APPROVAL_TOOL_NAME = 'approval' as const;
/**
* Stable code on `BuilderNotConfiguredError` (`packages/cli/src/modules/agents/builder/errors.ts`)
* so callers that can't import that class directly (e.g. instance AI) can
* still detect the unconfigured state by matching the thrown error's `code`.
*/
export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED' as const;
/**
* The only two agent-builder tools that mutate the agent config. Mirrors
* `BUILDER_TOOLS.WRITE_CONFIG` / `PATCH_CONFIG` in
* `packages/cli/src/modules/agents/builder/builder-tool-names.ts`.
*/
export const CONFIG_MUTATION_TOOL_NAMES = ['write_config', 'patch_config'] as const;
export const interactiveToolNameSchema = z.union([
z.literal(ASK_LLM_TOOL_NAME),
z.literal(ASK_CREDENTIAL_TOOL_NAME),
z.literal(ASK_EMBEDDING_CREDENTIAL_TOOL_NAME),
z.literal(ASK_QUESTION_TOOL_NAME),
z.literal(ASK_QUESTIONS_TOOL_NAME),
z.literal(CONFIGURE_CHANNEL_TOOL_NAME),
]);
export type InteractiveToolName = z.infer<typeof interactiveToolNameSchema>;
// ---------------------------------------------------------------------------
// ask_llm
// ---------------------------------------------------------------------------
export const askLlmInputSchema = z.object({
purpose: z
.string()
.optional()
.describe(
'Short sentence describing why the model is needed, e.g. "Main LLM for the Slack triage agent"',
),
});
export const askLlmResumeSchema = z.object({
provider: z.string(),
model: z.string(),
credentialId: z.string(),
credentialName: z.string(),
});
export type AskLlmInput = z.infer<typeof askLlmInputSchema>;
export type AskLlmResume = z.infer<typeof askLlmResumeSchema>;
// ---------------------------------------------------------------------------
// ask_credential
// ---------------------------------------------------------------------------
@@ -83,53 +50,17 @@ export const askCredentialInputSchema = z.object({
.describe('Credential key on node.credentials, e.g. "slackApi"'),
});
export const askCredentialResumeSchema = z.union([
z.object({ credentialId: z.string(), credentialName: z.string() }),
z.object({ skipped: z.literal(true) }),
]);
export type AskCredentialInput = z.infer<typeof askCredentialInputSchema>;
export type AskCredentialResume = z.infer<typeof askCredentialResumeSchema>;
export const askEmbeddingCredentialResumeSchema = askCredentialResumeSchema;
export type AskEmbeddingCredentialResume = AskCredentialResume;
/**
* Suspend/resume for `ask_credential` and `ask_embedding_credential` now use
* the shared instance-AI-compatible contract (`agents/agent-interaction.schema.ts`,
* re-exported below) instead of a builder-only shape — see that module for
* the full suspend payload (`credentialSuspendPayloadSchema`).
*/
// ---------------------------------------------------------------------------
// ask_question
// ---------------------------------------------------------------------------
export const askQuestionOptionSchema = z.object({
label: z.string().describe('Display label for this option'),
value: z.string().describe('Internal value for this option'),
description: z.string().optional().describe('Optional additional explanation'),
});
export const askQuestionInputSchema = z.object({
question: z.string().describe('The question to display to the user'),
options: z
.array(askQuestionOptionSchema)
.describe(
'Choices to present. Pass an empty array for an open-ended question (the card shows only a freeform input). With a single non-multiple option the tool auto-resolves to that option without rendering a card.',
),
allowMultiple: z
.boolean()
.optional()
.describe('If true the user may select more than one option; defaults to false'),
});
export const askQuestionResumeSchema = z.object({
values: z
.array(z.string())
.min(1)
.describe('Selected option values, or freeform text entered in the Other field.'),
});
export type AskQuestionOption = z.infer<typeof askQuestionOptionSchema>;
export type AskQuestionInput = z.infer<typeof askQuestionInputSchema>;
export type AskQuestionResume = z.infer<typeof askQuestionResumeSchema>;
// ---------------------------------------------------------------------------
// Discriminated union of all resume payloads (used by AgentBuildResumeDto)
// Cancellation
// ---------------------------------------------------------------------------
export const cancellationResumeSchema = z.object({
@@ -138,13 +69,3 @@ export const cancellationResumeSchema = z.object({
});
export type CancellationResumeData = z.infer<typeof cancellationResumeSchema>;
export const interactiveResumeDataSchema = z.union([
askLlmResumeSchema,
askEmbeddingCredentialResumeSchema,
askCredentialResumeSchema,
askQuestionResumeSchema,
cancellationResumeSchema,
]);
export type InteractiveResumeData = z.infer<typeof interactiveResumeDataSchema>;
@@ -0,0 +1,45 @@
import { credentialResumeSchema, questionsResumeSchema } from '../agent-interaction.schema';
import { AgentBuildResumeDto } from '../dto';
describe('AgentBuildResumeDto', () => {
const base = { runId: 'run-1', toolCallId: 'tc-1' };
it('does not strip answers from a questions-card resume', () => {
const resumeData = {
approved: true,
answers: [{ questionId: 'q1', selectedOptions: ['a'] }],
};
const result = AgentBuildResumeDto.safeParse({ ...base, resumeData });
expect(result.success).toBe(true);
if (!result.success) return;
// The DTO boundary must hand the payload through untouched — validation
// (and any stripping of unrecognized keys) is each tool's own job via
// `.resume(schema)`, not a shared union at the DTO layer.
expect(result.data.resumeData).toEqual(resumeData);
// Prove it also survives the tool layer's own validator intact.
const parsedByTool = questionsResumeSchema.parse(result.data.resumeData);
expect(parsedByTool.answers).toEqual(resumeData.answers);
});
it('still allows a plain credential-card denial to resolve as skipped', () => {
const resumeData = { approved: false };
const result = AgentBuildResumeDto.safeParse({ ...base, resumeData });
expect(result.success).toBe(true);
if (!result.success) return;
const parsedByTool = credentialResumeSchema.parse(result.data.resumeData);
expect(parsedByTool).toEqual(resumeData);
});
it('rejects a credential-card resume claiming approval with no selection', () => {
// `{ approved: true }` has no `credentials` to resolve, so it would
// otherwise silently resolve as skipped despite claiming success.
expect(() => credentialResumeSchema.parse({ approved: true })).toThrow();
});
});
@@ -0,0 +1,119 @@
import { z } from 'zod';
import { channelConfigSchema, credentialRequestSchema } from '../schemas/instance-ai.schema';
/**
* Shared interaction contract between the agents-module builder (its own UI
* chat) and instance AI (running the builder as a sub-agent). Both surfaces
* suspend/resume with exactly these shapes — no per-surface translation.
*
* The tool name is the discriminator on the wire (see
* `agent-builder-interactive.ts`'s doc comment); these schemas cover the
* three tools whose suspend/resume payload matches instance-AI's own
* confirmation-request/confirm-response wire contract:
* `ask_questions`, `ask_credential`/`ask_embedding_credential`, and
* `configure_channel`.
*/
export const ASK_QUESTIONS_TOOL_NAME = 'ask_questions' as const;
export const CONFIGURE_CHANNEL_TOOL_NAME = 'configure_channel' as const;
/**
* Stable code on `BuilderNotConfiguredError` (`packages/cli/src/modules/agents/builder/errors.ts`)
* so callers that can't import that class directly (e.g. instance AI) can
* still detect the unconfigured state by matching the thrown error's `code`.
*/
export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED' as const;
/**
* The only two agent-builder tools that mutate the agent config. Mirrors
* `BUILDER_TOOLS.WRITE_CONFIG` / `PATCH_CONFIG` in
* `packages/cli/src/modules/agents/builder/builder-tool-names.ts`.
*/
export const CONFIG_MUTATION_TOOL_NAMES = ['write_config', 'patch_config'] as const;
// ---------------------------------------------------------------------------
// ask_questions
// ---------------------------------------------------------------------------
export const interactionQuestionSchema = z.object({
id: z.string(),
question: z.string(),
type: z.enum(['single', 'multi', 'text']),
options: z.array(z.string()).optional(),
});
export type InteractionQuestion = z.infer<typeof interactionQuestionSchema>;
export const questionsSuspendPayloadSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: z.literal('info'),
inputType: z.literal('questions'),
questions: z.array(interactionQuestionSchema),
introMessage: z.string().optional(),
});
export type QuestionsSuspendPayload = z.infer<typeof questionsSuspendPayloadSchema>;
/** One answered (or explicitly skipped) question — mirrors `questionsConfirmSchema`'s answer shape 1:1. */
export const questionAnswerSchema = z.object({
questionId: z.string(),
selectedOptions: z.array(z.string()),
customText: z.string().optional(),
skipped: z.boolean().optional(),
});
export type QuestionAnswer = z.infer<typeof questionAnswerSchema>;
/**
* `approved` has no top-level meaning on the FE's own questions wire DTO
* (`questionsConfirmSchema`) — it only appears when `toConfirmationData`
* flattens a dismissal to `{ approved: false }` with no `answers`. Optional
* here so both shapes parse.
*/
export const questionsResumeSchema = z.object({
approved: z.boolean().optional(),
answers: z.array(questionAnswerSchema).optional(),
});
export type QuestionsResumeData = z.infer<typeof questionsResumeSchema>;
// ---------------------------------------------------------------------------
// ask_credential / ask_embedding_credential
// ---------------------------------------------------------------------------
export const credentialSuspendPayloadSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: z.literal('info'),
credentialRequests: z.array(credentialRequestSchema).min(1),
credentialFlow: z.object({ stage: z.literal('generic') }),
});
export type CredentialSuspendPayload = z.infer<typeof credentialSuspendPayloadSchema>;
/**
* Union mirrors every shape the FE's credential-selection confirm DTO can
* collapse to: a selection (`{ credentials }`), an explicit denial
* (`{ approved: false }`), or a dismissal (`{ skipped: true }`).
*/
export const credentialResumeSchema = z.union([
z.object({ credentials: z.record(z.string()) }),
z.object({ approved: z.literal(false) }),
z.object({ skipped: z.literal(true) }),
]);
export type CredentialResumeData = z.infer<typeof credentialResumeSchema>;
// ---------------------------------------------------------------------------
// configure_channel
// ---------------------------------------------------------------------------
export const channelSuspendPayloadSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: z.literal('info'),
channelConfig: channelConfigSchema,
projectId: z.string(),
});
export type ChannelSuspendPayload = z.infer<typeof channelSuspendPayloadSchema>;
export const channelResumeSchema = z.object({
approved: z.boolean(),
});
export type ChannelResumeData = z.infer<typeof channelResumeSchema>;
+7 -2
View File
@@ -1,7 +1,6 @@
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
import { interactiveResumeDataSchema } from '../agent-builder-interactive';
import { AgentVectorStoreConfigSchema } from './agent-json-config.schema';
import { agentTaskSchema } from './agent-task.schema';
import { paginationSchema } from '../dto/pagination/pagination.dto';
@@ -209,7 +208,13 @@ export class AgentChatMessageDto extends Z.class({
export class AgentBuildResumeDto extends Z.class({
runId: z.string().min(1),
toolCallId: z.string().min(1),
resumeData: interactiveResumeDataSchema,
// Deliberately untyped at this boundary: the possible resume shapes overlap
// (e.g. credential's `{approved}` matches questions' `{approved, answers}`
// and a non-discriminated union would parse against whichever member
// matches first, silently stripping fields the "wrong" schema doesn't
// know about). Each interactive tool validates its own resume payload via
// `.resume(schema)`, same as AgentChatResumeDto below.
resumeData: z.unknown(),
}) {}
export class AgentChatResumeDto extends Z.class({
+3 -20
View File
@@ -14,34 +14,17 @@ export {
AGENT_BUILDER_AVAILABLE_AI_UTILITY_TOOL_NODE_TYPES,
AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES,
} from '../agent-builder-tool-node-types';
// ASK_QUESTIONS_TOOL_NAME / CONFIGURE_CHANNEL_TOOL_NAME come from
// ./agent-interaction.schema (re-exported below via `export *`).
export {
ASK_LLM_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
APPROVAL_TOOL_NAME,
BUILDER_NOT_CONFIGURED_CODE,
CONFIG_MUTATION_TOOL_NAMES,
interactiveToolNameSchema,
askLlmInputSchema,
askLlmResumeSchema,
askCredentialInputSchema,
askCredentialResumeSchema,
askEmbeddingCredentialResumeSchema,
askQuestionOptionSchema,
askQuestionInputSchema,
askQuestionResumeSchema,
cancellationResumeSchema,
interactiveResumeDataSchema,
type InteractiveToolName,
type AskLlmInput,
type AskLlmResume,
type AskCredentialInput,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskQuestionOption,
type AskQuestionInput,
type AskQuestionResume,
type CancellationResumeData,
type InteractiveResumeData,
} from '../agent-builder-interactive';
export * from './agent-interaction.schema';
+1 -1
View File
@@ -226,7 +226,7 @@ graph LR
If any step fails, the agent reads the error output, fixes the code, and retries. This loop runs entirely inside the sandbox — the n8n host is never involved until the final save.
Agent building does not go through the sandbox at all.
Agent building does not go through the sandbox at all. The `build-agent` orchestration tool delegates each turn to the agents-module builder (`AgentsBuilderService`), which runs host-side as a sub-agent — there are no agent-config files in the workspace, and no sandbox is required for agent building.
## Boundaries
+6 -6
View File
@@ -682,12 +682,12 @@ Delegates agent building to the agents-module builder chat
one conversational turn per call. Registered in `createOrchestrationTools`
only when the host provides `builderDelegate` (agents module active). The
builder's own prompt and tools drive the build, but its interactive tools
(`ask_llm`, `ask_question`, `ask_credential`, `ask_embedding_credential`) are
excluded from this session — the builder cannot suspend mid-turn and must
complete every call, reporting any open questions as plain text at the end of
its reply (`builderReply`). Builder session state is keyed to instance-AI-scoped
threads (`ia-builder:<threadId>:<agentId>`) and never appears in the
agents-module builder UI.
(`ask_questions`, `ask_credential`, `ask_embedding_credential`,
`configure_channel`) are excluded from this session — the builder cannot
suspend mid-turn and must complete every call, reporting any open questions as
plain text at the end of its reply (`builderReply`). Builder session state is
keyed to instance-AI-scoped threads (`ia-builder:<threadId>:<agentId>`) and
never appears in the agents-module builder UI.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
@@ -66,7 +66,7 @@ function suspendingStream(): BuilderTurnStream {
{
type: 'tool-call-suspended',
toolCallId: 'call-1',
toolName: 'ask_question',
toolName: 'ask_questions',
suspendPayload: { message: 'unexpected question', severity: 'info' },
},
],
@@ -4,13 +4,13 @@
* per invocation.
*
* This is the non-interactive contract: the delegate session excludes every
* interactive builder tool (`ask_llm`, `ask_question`, `ask_credential`,
* `ask_embedding_credential` — see `NON_INTERACTIVE_EXCLUDED_TOOL_NAMES` in
* the cli delegate adapter), so the builder cannot suspend mid-turn and must
* complete, error, or be cancelled on every call. Any open questions the
* builder still has come back as plain text at the end of its reply — the
* calling assistant relays those to the user and sends the answers back
* through another `build-agent` call.
* interactive builder tool (`ask_questions`, `ask_credential`,
* `ask_embedding_credential`, `configure_channel` — see
* `NON_INTERACTIVE_EXCLUDED_TOOL_NAMES` in the cli delegate adapter), so the
* builder cannot suspend mid-turn and must complete, error, or be cancelled
* on every call. Any open questions the builder still has come back as plain
* text at the end of its reply — the calling assistant relays those to the
* user and sends the answers back through another `build-agent` call.
*
* The builder session is keyed to an instance-AI-scoped thread id
* (`ia-builder:<threadId>:<agentId>`) so nothing appears in the agents-module
@@ -1,5 +1,10 @@
import { SUB_AGENT_MAX_CHILDREN_MAX, SUB_AGENT_MAX_CHILDREN_MIN } from '@n8n/api-types';
import {
IMPORTANT_SECTION,
INTERACTIVE_TOOLS_SECTION,
WORKFLOW_SECTION,
} from '../agents-builder-prompts';
import { getBuilderRuntimeSkills } from '../skills';
describe('agents builder integrations prompt', () => {
@@ -13,6 +18,35 @@ describe('agents builder integrations prompt', () => {
});
});
describe('chat-channel credential guidance', () => {
it('mandates configure_channel and forbids ask_credential for chat-channel credentials', () => {
expect(INTERACTIVE_TOOLS_SECTION).toContain(
'NEVER use it for a chat-channel\n credential — use `configure_channel` instead.',
);
expect(INTERACTIVE_TOOLS_SECTION).toContain(
'`configure_channel`: ALWAYS use this to connect a chat platform',
);
expect(IMPORTANT_SECTION).toContain(
'`configure_channel` (never `ask_credential`) for chat-channel',
);
const integrationsSkill = getBuilderRuntimeSkills().find(
(skill) => skill.id === 'agent-builder-integrations',
);
expect(integrationsSkill?.instructions).toContain(
'ALWAYS use `configure_channel` for chat-channel\n credentials — never `ask_credential`',
);
});
it('references ask_questions in the batching guidance', () => {
expect(WORKFLOW_SECTION).toContain(
'Use `ask_questions` for clarifying questions with discrete options, batching',
);
expect(IMPORTANT_SECTION).toContain('`ask_questions` (discrete options for a known set');
expect(IMPORTANT_SECTION).toContain('batch multiple questions into one call');
});
});
describe('MCP skill availability', () => {
it('includes the MCP skill', () => {
const skills = getBuilderRuntimeSkills();
@@ -40,7 +74,7 @@ describe('sub-agent skill availability', () => {
expect(skill).toBeDefined();
expect(skill?.instructions).toContain('`delegate_subagent`');
expect(skill?.instructions).toContain('Call `list_sub_agents`');
expect(skill?.instructions).toContain('`allowMultiple: true`');
expect(skill?.instructions).toContain('`type: "multi"`');
expect(skill?.instructions).toContain('subAgentId: "inline"');
expect(skill?.instructions).toContain('`subAgents.maxChildren`');
expect(skill?.instructions).toContain(
@@ -284,23 +284,38 @@ describe('AgentsBuilderService session isolation', () => {
it('omits standard tools named in session.excludeTools while still registering the rest', async () => {
const { service, user, credentialProvider } = setup({
json: [fakeTool('ask_question'), fakeTool('read_config')],
json: [fakeTool('ask_questions'), fakeTool('read_config')],
shared: [fakeTool('ask_credential')],
});
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
excludeTools: ['ask_question'],
excludeTools: ['ask_questions'],
}),
);
expect(agentsSdkMocks.registeredToolNames).not.toContain('ask_question');
expect(agentsSdkMocks.registeredToolNames).not.toContain('ask_questions');
expect(agentsSdkMocks.registeredToolNames).toEqual(
expect.arrayContaining(['read_config', 'ask_credential']),
);
});
it('omits the integrations skill when session.excludeTools excludes configure_channel', async () => {
const { service, user, credentialProvider } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
excludeTools: ['configure_channel'],
}),
);
expect(agentsSdkMocks.skillsCalls).toHaveLength(1);
const skills = agentsSdkMocks.skillsCalls[0] as Array<{ id: string }>;
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(false);
});
it('includes the integrations skill without a session', async () => {
const { service, user, credentialProvider } = setup();
@@ -82,10 +82,11 @@ tool-capable.
Treat this list as authoritative for model recommendations. Use these models
when the user does not know what model to pick. Prefer a recommended model for
a provider the user has credentials for; then call resolve_llm with that
provider and model, or ask_llm if the user needs to choose a credential.
provider and model, or ask via ask_questions if the user needs to choose a
credential.
Do not mention models outside this list unless the user explicitly names one
and resolve_llm validates it. Do not write a model or credential directly
without a resolve_llm or ask_llm result.
without a resolve_llm result.
${rows.join('\n')}`;
}
@@ -101,21 +101,27 @@ must be persisted exactly as returned.
Once you are building, ask for any specific decision, choice, value, or
clarification through one of these tools rather than in plain prose. Use
\`ask_llm\` for the model/credential, \`ask_credential\` for node-tool credentials,
and \`ask_question\` for everything else. Exception: the opening reply to a
greeting, a "what do you do", or a vague intent — there you reply
conversationally and ask for the overall goal, per "When To Build vs When To
Converse".
\`ask_credential\` for node-tool credentials, \`configure_channel\` for
chat-channel connections, and \`ask_questions\` for everything else, including
the model/credential choice — resolve the answer with \`resolve_llm\`.
Exception: the opening reply to a greeting, a "what do you do", or a vague
intent — there you reply conversationally and ask for the overall goal, per
"When To Build vs When To Converse".
- \`ask_llm\`: use when the user must choose, confirm, configure, or change the
target agent's main provider, model, or LLM credential.
- \`ask_credential\`: use once per required node-tool credential slot before
the config mutation that introduces the tool.
- \`ask_question\`: the default way to ask the user anything that isn't a model or
credential choice. Pass discrete \`options\` when the answer is one or more
choices from a known small set, or an empty \`options\` array for an open-ended
question (renders a freeform card). Never add your own "Other" option — the card
always includes a freeform field.
the config mutation that introduces the tool. NEVER use it for a chat-channel
credential — use \`configure_channel\` instead.
- \`configure_channel\`: ALWAYS use this to connect a chat platform (Slack,
Telegram, ...) as an agent channel, with a type from \`list_integration_types\`.
The setup UI creates and persists the credential itself.
- \`ask_questions\`: the default way to ask the user anything that isn't a
node-tool credential or channel choice, including when the user must choose,
confirm, configure, or change the target agent's main provider, model, or
LLM credential — resolve the answer with \`resolve_llm\`. Batch every
question you currently need into a single call instead of asking one at a
time. Each question is single-select, multi-select, or free-text; pass
discrete \`options\` for a known small set of choices, or \`type: "text"\` for
an open-ended question.
- Never call two interactive tools in parallel. The run suspends on the first.
- Never re-ask a question the user already answered in this thread.
- After resume, continue with the next concrete tool action. Do not narrate the
@@ -169,15 +175,18 @@ change, call \`read_config\` again.`;
export const IMPORTANT_SECTION = `\
## Important
- Credentials are user-controlled. Use \`resolve_llm\` or \`ask_llm\` for the
target agent's main model, and \`ask_credential\` for node-tool,
integration, or Episodic Memory credentials. Never copy credential IDs from
\`list_credentials\` into config.
- Credentials are user-controlled. Use \`resolve_llm\` (asking via
\`ask_questions\` first when the user must choose) for the target agent's
main model, \`ask_credential\` for node-tool or Episodic Memory credentials,
and \`configure_channel\` (never \`ask_credential\`) for chat-channel
credentials. Never copy credential IDs from \`list_credentials\` into config.
- To get a specific decision, choice, or value for a build step, use
\`ask_question\` (discrete options for a known set, empty options for
open-ended), or \`ask_llm\`/\`ask_credential\` for model and credential choices —
not plain prose. Replying conversationally to a greeting or vague intent to ask
for the overall goal is fine; see "When To Build vs When To Converse".
\`ask_questions\` (discrete options for a known set, \`type: "text"\` for
open-ended; batch multiple questions into one call) for model, credential,
and other choices, or \`ask_credential\`/\`configure_channel\` for node-tool
and channel credentials — not plain prose. Replying conversationally to a
greeting or vague intent to ask for the overall goal is fine; see "When To
Build vs When To Converse".
- Tool preference order for real-world integrations:
1. MCP servers (\`search_mcp_servers\`) — always check first
2. Node tools (\`search_nodes\`)
@@ -218,9 +227,11 @@ export const WORKFLOW_SECTION = `\
## Workflow
1. If the agent has no \`instructions\` and \`credential\` yet, call
\`resolve_llm\` when the user specified a provider/model, or call \`ask_llm\` if user didn't specify a provider/model.
\`resolve_llm\` when the user specified a provider/model, or ask via
\`ask_questions\` and call \`resolve_llm\` with the answer if they didn't.
2. Draft real target-agent \`instructions\`; never write empty placeholders.
3. Use \`ask_question\` for clarifying questions with discrete options.
3. Use \`ask_questions\` for clarifying questions with discrete options, batching
multiple questions into one call.
4. Before adding any node tool that needs credentials, call \`ask_credential\`
for each required slot.
5. Prefer existing workflow tools and node tools over custom tools for
@@ -235,7 +246,8 @@ export const FEW_SHOT_FLOWS_SECTION = `\
## Example flows
### New agent: "Build me a Slack triage agent"
1. \`ask_llm({ purpose: "Choose a model" })\` -> resolved provider, model, and credential.
1. \`ask_questions({ ... })\` for the model choice, then
\`resolve_llm({ provider, model })\` -> resolved provider, model, and credential.
2. \`search_nodes({ query: "slack" })\`, then \`get_node_types(...)\`.
3. \`ask_credential(...)\` for the Slack credential slot.
4. \`read_config()\`.
@@ -248,7 +260,8 @@ export const FEW_SHOT_FLOWS_SECTION = `\
\`credential\`, and requested instructions.
### Change the existing model
1. \`ask_llm({ purpose: "Choose a different model" })\`.
1. \`ask_questions({ ... })\` for the new model choice, then
\`resolve_llm({ provider, model })\`.
2. \`read_config()\`.
3. \`patch_config(...)\` replacing \`/model\` and \`/credential\`.
@@ -275,7 +288,7 @@ export const FEW_SHOT_FLOWS_SECTION = `\
\`metadata.nodeTypeName\` when returned by \`search_mcp_servers\`).
### Ambiguous request: "Make it post somewhere"
1. \`ask_question(...)\` with the known destination choices.
1. \`ask_questions(...)\` with the known destination choices.
2. Continue the chosen branch with node discovery, credentials, and config
mutation.`;
@@ -52,8 +52,8 @@ import { buildGetResourceLocatorOptionsTool } from './get-resource-locator-optio
import {
buildAskCredentialTool,
buildAskEmbeddingCredentialTool,
buildAskLlmTool,
buildAskQuestionTool,
buildAskQuestionsTool,
buildConfigureChannelTool,
buildResolveLlmTool,
} from './interactive';
import type { ModelLookup } from './interactive/resolve-llm.tool';
@@ -513,8 +513,15 @@ export class AgentsBuilderToolsService {
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
}),
buildAskLlmTool(),
buildAskQuestionTool(),
buildAskQuestionsTool(),
buildConfigureChannelTool({
agentId,
projectId,
listChatIntegrationTypes: () =>
this.agentIntegrationPersistenceService
.listChatIntegrations()
.map((integration) => integration.type),
}),
buildVerifyMcpServerTool({
credentialProvider,
oauthService: this.oauthService,
@@ -624,8 +631,8 @@ export class AgentsBuilderToolsService {
'parameter for the template. You MUST NOT call this tool with a vague, broad, or placeholder ' +
'objective, an objective missing any section, or an unclear schedule. First make sure you can ' +
'fill every section of the template and know how often/when it should run; if anything is ' +
'ambiguous, ask the user clarifying questions (ask_question with discrete options for choices, ' +
'or empty options for open-ended) and only call create_task once the objective is complete and the cadence ' +
'ambiguous, ask the user clarifying questions (ask_questions with discrete options for choices, ' +
'or type: "text" for open-ended) and only call create_task once the objective is complete and the cadence ' +
'is known. This adds a `{ type: "task", id, enabled }` ref to the agent config (config.tasks) ' +
'and the task starts running once the agent is (re)published. Returns { ok: true, task } or ' +
'{ ok: false, errors }.',
@@ -239,7 +239,7 @@ export class AgentsBuilderService {
const finalInstructions = session?.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
const runtimeSkills = getBuilderRuntimeSkills();
const runtimeSkills = getBuilderRuntimeSkills(session?.excludeTools);
const tools = this.agentsBuilderToolsService.getTools(
agentId,
@@ -2,15 +2,15 @@
* Tool names used by the agent builder. Centralised so prompts, the SSE event
* routing, and tests can't drift on string typos.
*
* The interactive tools (`ask_llm`, `ask_credential`, `ask_embedding_credential`,
* `ask_question`) are NOT listed here — their names live in `@n8n/api-types`
* (`agent-builder-interactive.ts`) alongside the suspend/resume schemas they
* share with instance AI's FE cards.
* The interactive tools (`ask_credential`, `ask_embedding_credential`,
* `ask_questions`, `configure_channel`) are NOT listed here — their names live
* in `@n8n/api-types` (`agent-builder-interactive.ts` / `agents/agent-interaction.schema.ts`)
* alongside the suspend/resume schemas they share with instance AI's FE cards.
*/
export const BUILDER_TOOLS = {
READ_CONFIG: 'read_config',
// WRITE_CONFIG / PATCH_CONFIG values must match `CONFIG_MUTATION_TOOL_NAMES`
// in `@n8n/api-types` (agent-builder-interactive.ts).
// in `@n8n/api-types` (agents/agent-interaction.schema.ts).
WRITE_CONFIG: 'write_config',
PATCH_CONFIG: 'patch_config',
BUILD_CUSTOM_TOOL: 'build_custom_tool',
@@ -66,10 +66,12 @@ describe('ask_credential tool', () => {
});
});
it('adds the node credentials map when resuming from a selected credential', async () => {
const credentialProvider = makeProvider([]);
it('resolves the display name from the credential list when resuming with a selection', async () => {
const credentialProvider = makeProvider([
{ id: 'c9', name: 'Picked', type: 'linearOAuth2Api' },
]);
const tool = buildAskCredentialTool({ credentialProvider });
const ctx = makeCtx({ resumeData: { credentialId: 'c9', credentialName: 'Picked' } });
const ctx = makeCtx({ resumeData: { credentials: { linearOAuth2Api: 'c9' } } });
const result = await tool.handler!(
{
@@ -89,15 +91,50 @@ describe('ask_credential tool', () => {
});
});
it('suspends when multiple credentials of the type exist', async () => {
it('falls back to the id as the name when the selected credential is not in the list', async () => {
const credentialProvider = makeProvider([]);
const tool = buildAskCredentialTool({ credentialProvider });
const ctx = makeCtx({ resumeData: { credentials: { slackApi: 'c9' } } });
const result = await tool.handler!(
{ purpose: 'Slack', credentialType: 'slackApi' },
ctx as never,
);
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'c9',
credentials: { slackApi: { id: 'c9', name: 'c9' } },
});
});
it('suspends with a credentialRequests payload including existingCredentials when multiple credentials of the type exist', async () => {
const credentialProvider = makeProvider([
{ id: 'c1', name: 'Personal Slack', type: 'slackApi' },
{ id: 'c2', name: 'Workspace Slack', type: 'slackApi' },
]);
const tool = buildAskCredentialTool({ credentialProvider });
const ctx = makeCtx();
await tool.handler!({ purpose: 'Slack', credentialType: 'slackApi' }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
await tool.handler!({ purpose: 'Connect Slack', credentialType: 'slackApi' }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledWith(
expect.objectContaining({
requestId: expect.any(String),
message: 'Connect Slack',
severity: 'info',
credentialRequests: [
{
credentialType: 'slackApi',
reason: 'Connect Slack',
existingCredentials: [
{ id: 'c1', name: 'Personal Slack' },
{ id: 'c2', name: 'Workspace Slack' },
],
},
],
credentialFlow: { stage: 'generic' },
}),
);
});
it('suspends when no credentials of the type exist', async () => {
@@ -137,26 +174,19 @@ describe('ask_credential tool', () => {
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('returns selected credential after resume without consulting the provider', async () => {
it('returns skipped when the credentials map has no entry for the requested type', async () => {
const credentialProvider = makeProvider([]);
const tool = buildAskCredentialTool({ credentialProvider });
const ctx = makeCtx({ resumeData: { credentialId: 'c9', credentialName: 'Picked' } });
const ctx = makeCtx({ resumeData: { credentials: {} } });
const result = await tool.handler!(
{ purpose: 'Slack', credentialType: 'slackApi' },
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'Picked',
credentials: {
slackApi: { id: 'c9', name: 'Picked' },
},
});
expect(result).toEqual({ skipped: true });
});
it('returns skipped resumeData so the builder can continue without credentials', async () => {
it('returns skipped when the resume has no credentials map (explicit skip or denial)', async () => {
const credentialProvider = makeProvider([]);
const tool = buildAskCredentialTool({ credentialProvider });
const ctx = makeCtx({ resumeData: { skipped: true } });
@@ -211,19 +241,23 @@ describe('ask_embedding_credential tool', () => {
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledWith({
purpose: 'Episodic Memory embeddings',
credentialType: 'openAiApi',
});
expect(ctx.suspend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Episodic Memory embeddings',
credentialRequests: [expect.objectContaining({ credentialType: 'openAiApi' })],
}),
);
});
it('returns selected credential after resume when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([]);
it('resolves the display name from the credential list when resuming, when assistant proxy is unavailable', async () => {
const credentialProvider = makeProvider([
{ id: 'c9', name: 'Picked OpenAI', type: 'openAiApi' },
]);
const tool = buildAskEmbeddingCredentialTool({
credentialProvider,
isAssistantProxyEnabled: () => false,
});
const ctx = makeCtx({ resumeData: { credentialId: 'c9', credentialName: 'Picked OpenAI' } });
const ctx = makeCtx({ resumeData: { credentials: { openAiApi: 'c9' } } });
const result = await tool.handler!(
{ purpose: 'Episodic Memory embeddings', credentialType: 'openAiApi' },
@@ -231,7 +265,6 @@ describe('ask_embedding_credential tool', () => {
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(credentialProvider.list).not.toHaveBeenCalled();
expect(result).toEqual({
credentialId: 'c9',
credentialName: 'Picked OpenAI',
@@ -1,47 +0,0 @@
import type { Mock } from 'vitest';
import { buildAskLlmTool } from '../ask-llm.tool';
interface TestCtx {
resumeData?: unknown;
suspend: Mock;
}
function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
return { resumeData: overrides?.resumeData, suspend: vi.fn(async (x: unknown) => x) };
}
describe('ask_llm tool', () => {
it('instructs the builder to render the picker instead of asking in prose', () => {
const tool = buildAskLlmTool();
expect(tool.systemInstruction).toContain('Never ask the user in plain text');
expect(tool.systemInstruction).toContain('call ask_llm');
});
it('suspends on first invocation so the user can choose', async () => {
const tool = buildAskLlmTool();
const ctx = makeCtx();
await tool.handler!({ purpose: 'Main LLM' }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledWith({ purpose: 'Main LLM' });
});
it('returns resumeData verbatim after resume', async () => {
const tool = buildAskLlmTool();
const ctx = makeCtx({
resumeData: {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
credentialId: 'cX',
credentialName: 'Picked',
},
});
const result = await tool.handler!({ purpose: 'Main LLM' }, ctx as never);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual({
provider: 'anthropic',
model: 'claude-sonnet-4-6',
credentialId: 'cX',
credentialName: 'Picked',
});
});
});
@@ -1,80 +0,0 @@
import type { Mock } from 'vitest';
import { buildAskQuestionTool } from '../ask-question.tool';
interface TestCtx {
resumeData?: unknown;
suspend: Mock;
}
function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
return {
resumeData: overrides?.resumeData,
suspend: vi.fn(async (x: unknown) => x),
};
}
describe('ask_question tool', () => {
const tool = buildAskQuestionTool();
it('auto-resolves to the only option when options.length === 1', async () => {
const ctx = makeCtx();
const result = await tool.handler!(
{ question: 'Pick one', options: [{ label: 'Slack', value: 'slack' }] },
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual({ values: ['slack'] });
});
it('suspends for a multi-select question with one option', async () => {
const ctx = makeCtx();
await tool.handler!(
{
question: 'Pick subagents',
options: [{ label: 'Research Agent', value: 'agent-research' }],
allowMultiple: true,
},
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('suspends when there are multiple options', async () => {
const ctx = makeCtx();
await tool.handler!(
{
question: 'Pick one',
options: [
{ label: 'Slack', value: 'slack' },
{ label: 'Discord', value: 'discord' },
],
},
ctx as never,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('suspends (open-ended freeform card) when options is empty', async () => {
const ctx = makeCtx();
await tool.handler!({ question: 'What should it do?', options: [] }, ctx as never);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('returns resumeData verbatim after resume', async () => {
const ctx = makeCtx({ resumeData: { values: ['discord'] } });
const result = await tool.handler!(
{
question: 'Pick',
options: [
{ label: 'Slack', value: 'slack' },
{ label: 'Discord', value: 'discord' },
],
},
ctx as never,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual({ values: ['discord'] });
});
});
@@ -0,0 +1,229 @@
import type { InterruptibleToolContext } from '@n8n/agents';
import type { Mock } from 'vitest';
import { buildAskQuestionsTool } from '../ask-questions.tool';
interface TestCtx {
resumeData?: unknown;
suspend: Mock;
}
function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
return {
resumeData: overrides?.resumeData,
suspend: vi.fn(async (x: unknown) => x),
};
}
describe('ask_questions tool', () => {
const tool = buildAskQuestionsTool();
it('auto-resolves a single single-select question with exactly one option', async () => {
const ctx = makeCtx();
const result = await tool.handler!(
{ questions: [{ question: 'Pick one', type: 'single', options: ['slack'] }] },
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual({
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['slack'], question: 'Pick one' }],
});
});
it('suspends a multi-select question with one option', async () => {
const ctx = makeCtx();
await tool.handler!(
{ questions: [{ question: 'Pick subagents', type: 'multi', options: ['agent-research'] }] },
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
});
it('suspends with all questions batched into a single inputType: questions card', async () => {
const ctx = makeCtx();
await tool.handler!(
{
questions: [
{ question: 'Which region?', type: 'single', options: ['us', 'eu'] },
{ question: 'Any notes?', type: 'text' },
],
},
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
const payload = ctx.suspend.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
requestId: expect.any(String),
message: 'The agent builder has questions',
severity: 'info',
inputType: 'questions',
questions: [
{ id: 'q1', question: 'Which region?', type: 'single', options: ['us', 'eu'] },
{ id: 'q2', question: 'Any notes?', type: 'text' },
],
}),
);
});
it('defaults missing question ids to q1..qN while preserving explicit ids', async () => {
const ctx = makeCtx();
await tool.handler!(
{
questions: [
{ id: 'custom-id', question: 'First?', type: 'text' },
{ question: 'Second?', type: 'text' },
],
},
ctx as unknown as InterruptibleToolContext,
);
const payload = ctx.suspend.mock.calls[0][0] as { questions: Array<{ id: string }> };
expect(payload.questions.map((q) => q.id)).toEqual(['custom-id', 'q2']);
});
it('skips a default id already claimed by an explicit id, keeping ids unique', async () => {
const ctx = makeCtx();
await tool.handler!(
{
questions: [
{ question: 'First?', type: 'text' },
{ id: 'q2', question: 'Second?', type: 'text' },
{ question: 'Third?', type: 'text' },
],
},
ctx as unknown as InterruptibleToolContext,
);
const payload = ctx.suspend.mock.calls[0][0] as { questions: Array<{ id: string }> };
const ids = payload.questions.map((q) => q.id);
expect(ids).toEqual(['q1', 'q2', 'q3']);
expect(new Set(ids).size).toBe(ids.length);
});
it('rejects duplicate explicit question ids', async () => {
const ctx = makeCtx();
await expect(
tool.handler!(
{
questions: [
{ id: 'dup', question: 'First?', type: 'text' },
{ id: 'dup', question: 'Second?', type: 'text' },
],
},
ctx as unknown as InterruptibleToolContext,
),
).rejects.toThrow('question ids must be unique');
});
it('uses introMessage as the suspend message and passes it through when provided', async () => {
const ctx = makeCtx();
await tool.handler!(
{
questions: [{ question: 'Which region?', type: 'single', options: ['us', 'eu'] }],
introMessage: 'A couple of quick questions',
},
ctx as unknown as InterruptibleToolContext,
);
const payload = ctx.suspend.mock.calls[0][0] as Record<string, unknown>;
expect(payload.message).toBe('A couple of quick questions');
expect(payload.introMessage).toBe('A couple of quick questions');
});
it('generates a fresh requestId on every suspend call', async () => {
const ctx1 = makeCtx();
const ctx2 = makeCtx();
await tool.handler!(
{ questions: [{ question: 'Q', type: 'text' }] },
ctx1 as unknown as InterruptibleToolContext,
);
await tool.handler!(
{ questions: [{ question: 'Q', type: 'text' }] },
ctx2 as unknown as InterruptibleToolContext,
);
const requestId1 = (ctx1.suspend.mock.calls[0][0] as Record<string, unknown>).requestId;
const requestId2 = (ctx2.suspend.mock.calls[0][0] as Record<string, unknown>).requestId;
expect(requestId1).not.toBe(requestId2);
});
it('returns answered: false when the resume is a dismissal', async () => {
const ctx = makeCtx({ resumeData: { approved: false } });
const result = await tool.handler!(
{ questions: [{ question: 'Which region?', type: 'text' }] },
ctx as unknown as InterruptibleToolContext,
);
expect(result).toEqual({ answered: false });
});
it('returns answered: false when resume has no answers', async () => {
const ctx = makeCtx({ resumeData: { approved: true } });
const result = await tool.handler!(
{ questions: [{ question: 'Which region?', type: 'text' }] },
ctx as unknown as InterruptibleToolContext,
);
expect(result).toEqual({ answered: false });
});
it('returns answered: false when every answer is explicitly skipped', async () => {
const ctx = makeCtx({
resumeData: { answers: [{ questionId: 'q1', selectedOptions: [], skipped: true }] },
});
const result = await tool.handler!(
{ questions: [{ question: 'Which region?', type: 'text' }] },
ctx as unknown as InterruptibleToolContext,
);
expect(result).toEqual({ answered: false });
});
it('returns answered: true with question text merged into each answer on resume', async () => {
const ctx = makeCtx({
resumeData: {
answers: [
{ questionId: 'q1', selectedOptions: ['us'] },
{ questionId: 'q2', selectedOptions: [], customText: 'no notes' },
],
},
});
const result = await tool.handler!(
{
questions: [
{ question: 'Which region?', type: 'single', options: ['us', 'eu'] },
{ question: 'Any notes?', type: 'text' },
],
},
ctx as unknown as InterruptibleToolContext,
);
expect(result).toEqual({
answered: true,
answers: [
{ questionId: 'q1', selectedOptions: ['us'], question: 'Which region?' },
{
questionId: 'q2',
selectedOptions: [],
customText: 'no notes',
question: 'Any notes?',
},
],
});
});
});
@@ -0,0 +1,115 @@
import type { InterruptibleToolContext } from '@n8n/agents';
import { channelConfigSchema } from '@n8n/api-types';
import type { Mock } from 'vitest';
import { buildConfigureChannelTool } from '../configure-channel.tool';
interface TestCtx {
resumeData?: unknown;
suspend: Mock;
}
function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
return {
resumeData: overrides?.resumeData,
suspend: vi.fn(async (x: unknown) => x),
};
}
describe('configure_channel tool', () => {
function buildTool(availableTypes: string[] = ['slack', 'telegram', 'linear']) {
return buildConfigureChannelTool({
agentId: 'agent-1',
projectId: 'project-1',
listChatIntegrationTypes: () => availableTypes,
});
}
it('suspends with a channelConfig payload conforming to channelConfigSchema on first call', async () => {
const ctx = makeCtx();
await buildTool().handler!(
{ integrationType: 'slack' },
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).toHaveBeenCalledTimes(1);
const payload = ctx.suspend.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toEqual(
expect.objectContaining({
requestId: expect.any(String),
message: expect.any(String),
severity: 'info',
channelConfig: { integrationType: 'slack', agentId: 'agent-1' },
projectId: 'project-1',
}),
);
expect(() => channelConfigSchema.parse(payload.channelConfig)).not.toThrow();
});
it('generates a fresh requestId on every suspend call', async () => {
const tool = buildTool();
const ctx1 = makeCtx();
const ctx2 = makeCtx();
await tool.handler!({ integrationType: 'slack' }, ctx1 as unknown as InterruptibleToolContext);
await tool.handler!({ integrationType: 'slack' }, ctx2 as unknown as InterruptibleToolContext);
const requestId1 = (ctx1.suspend.mock.calls[0][0] as Record<string, unknown>).requestId;
const requestId2 = (ctx2.suspend.mock.calls[0][0] as Record<string, unknown>).requestId;
expect(requestId1).not.toBe(requestId2);
});
it('rejects an integrationType not returned by listChatIntegrationTypes, listing what is available', async () => {
const ctx = makeCtx();
const result = await buildTool().handler!(
{ integrationType: 'discord' },
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
ok: false,
errors: [
expect.objectContaining({
message: expect.stringContaining('Unsupported chat channel "discord"'),
}),
],
}),
);
const emptyResult = (await buildTool([]).handler!(
{ integrationType: 'slack' },
ctx as unknown as InterruptibleToolContext,
)) as { errors: Array<{ message: string }> };
expect(emptyResult.errors[0].message).toContain('No chat channels are currently available.');
});
it('resume leg is handled before validation and returns connected: true on approval', async () => {
const ctx = makeCtx({ resumeData: { approved: true } });
// Deliberately pass a type not in the catalog — checkpoint-rebuild safety
// means the resume leg must not re-validate against the (possibly
// unavailable) integration catalog.
const result = await buildTool([]).handler!(
{ integrationType: 'slack' },
ctx as unknown as InterruptibleToolContext,
);
expect(ctx.suspend).not.toHaveBeenCalled();
expect(result).toEqual({ connected: true });
});
it('returns connected: false when the user skips (dismissal)', async () => {
const ctx = makeCtx({ resumeData: { approved: false } });
const result = await buildTool().handler!(
{ integrationType: 'slack' },
ctx as unknown as InterruptibleToolContext,
);
expect(result).toEqual({ connected: false });
});
});
@@ -5,10 +5,13 @@ import {
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
MANAGED_CREDENTIAL_TOKEN,
askCredentialInputSchema,
askCredentialResumeSchema,
credentialResumeSchema,
credentialSuspendPayloadSchema,
type AskCredentialInput,
type AskCredentialResume,
type CredentialResumeData,
type CredentialSuspendPayload,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
export interface AskCredentialToolDeps {
credentialProvider: CredentialProvider;
@@ -29,77 +32,110 @@ type AskCredentialToolResult =
function withNodeCredentialMap(
input: AskCredentialInput,
resume: AskCredentialResume,
credentialId: string,
credentialName: string,
): AskCredentialToolResult {
if ('skipped' in resume) return resume;
const credentialSlot = input.credentialSlot ?? input.credentialType;
return {
credentialId: resume.credentialId,
credentialName: resume.credentialName,
credentialId,
credentialName,
credentials: {
[credentialSlot]: {
id: resume.credentialId,
name: resume.credentialName,
},
[credentialSlot]: { id: credentialId, name: credentialName },
},
};
}
async function resolveCredentialSelection<TResult>(
/** Existing credentials of the requested type — used both for the suspend card and to resolve a display name on resume. */
async function listExistingCredentials(
credentialProvider: CredentialProvider,
credentialType: string,
): Promise<Array<{ id: string; name: string }>> {
const all = await credentialProvider.list();
return all.filter((c) => c.type === credentialType).map((c) => ({ id: c.id, name: c.name }));
}
/** Resolve the resume leg — a selection, a denial, or a dismissal — into the tool's output shape. */
async function resolveResume(
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
resumeData: CredentialResumeData,
credentialProvider: CredentialProvider,
): Promise<AskCredentialToolResult> {
if (!('credentials' in resumeData)) return { skipped: true };
const credentialId = resumeData.credentials[input.credentialType];
if (!credentialId) return { skipped: true };
const existingCredentials = await listExistingCredentials(
credentialProvider,
input.credentialType,
);
const match = existingCredentials.find((c) => c.id === credentialId);
return withNodeCredentialMap(input, credentialId, match?.name ?? credentialId);
}
async function resolveCredentialSelection(
input: AskCredentialInput,
ctx: InterruptibleToolContext<CredentialSuspendPayload, CredentialResumeData>,
deps: AskCredentialToolDeps,
mapResume: (resume: AskCredentialResume) => TResult,
): Promise<TResult> {
if (ctx.resumeData !== undefined) return mapResume(ctx.resumeData);
): Promise<AskCredentialToolResult> {
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
return await resolveResume(input, ctx.resumeData, deps.credentialProvider);
}
if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(input.credentialType)) {
throw new Error(
`Unknown credential type "${input.credentialType}". Use an exact n8n credential type name.`,
);
}
// If the user has exactly one credential of the requested type the
// picker has nothing to ask — auto-resolve so the LLM doesn't render
// a card the user can only confirm.
const all = await deps.credentialProvider.list();
const matching = all.filter((c) => c.type === input.credentialType);
if (matching.length === 1) {
return mapResume({
credentialId: matching[0].id,
credentialName: matching[0].name,
});
const existingCredentials = await listExistingCredentials(
deps.credentialProvider,
input.credentialType,
);
if (existingCredentials.length === 1) {
return withNodeCredentialMap(input, existingCredentials[0].id, existingCredentials[0].name);
}
return await ctx.suspend(input);
return await ctx.suspend({
requestId: nanoid(),
message: input.purpose,
severity: 'info' as const,
credentialRequests: [
{
credentialType: input.credentialType,
reason: input.purpose,
existingCredentials,
},
],
credentialFlow: { stage: 'generic' as const },
});
}
export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool {
return (
new Tool(ASK_CREDENTIAL_TOOL_NAME)
.description(
'Show a credential picker card in the chat UI and suspend until the user selects ' +
'a credential. Call ONCE per credential slot, BEFORE the write_config / patch_config ' +
'that introduces the node tool. Returns { credentialId, credentialName, credentials } on success ' +
'or { skipped: true } if the user skips credential setup so the tool can be added ' +
'without credentials. For node tools, copy the returned `credentials` object into `node.credentials`. Auto-resolves without ' +
'rendering a card when the user has exactly one credential of the requested type.',
)
.input(askCredentialInputSchema)
// Suspend payload mirrors the input — the discriminator on the wire is
// the tool name, not a separate `interactionType` field.
.suspend(askCredentialInputSchema)
.resume(askCredentialResumeSchema)
.handler(
async (
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
) => {
return await resolveCredentialSelection(input, ctx, deps, (resume) =>
withNodeCredentialMap(input, resume),
);
},
)
.build()
);
return new Tool(ASK_CREDENTIAL_TOOL_NAME)
.description(
'Show a credential picker card in the chat UI and suspend until the user selects ' +
'a credential. Call ONCE per credential slot, BEFORE the write_config / patch_config ' +
'that introduces the node tool. Returns { credentialId, credentialName, credentials } on success ' +
'or { skipped: true } if the user skips credential setup so the tool can be added ' +
'without credentials. For node tools, copy the returned `credentials` object into `node.credentials`. Auto-resolves without ' +
'rendering a card when the user has exactly one credential of the requested type.',
)
.input(askCredentialInputSchema)
.suspend(credentialSuspendPayloadSchema)
.resume(credentialResumeSchema)
.handler(
async (
input: AskCredentialInput,
ctx: InterruptibleToolContext<CredentialSuspendPayload, CredentialResumeData>,
) => {
return await resolveCredentialSelection(input, ctx, deps);
},
)
.build();
}
export function buildAskEmbeddingCredentialTool(deps: AskEmbeddingCredentialToolDeps): BuiltTool {
@@ -111,22 +147,17 @@ export function buildAskEmbeddingCredentialTool(deps: AskEmbeddingCredentialTool
'on success or { skipped: true } if the user skips credential setup.',
)
.input(askCredentialInputSchema)
.suspend(askCredentialInputSchema)
.resume(askCredentialResumeSchema)
.suspend(credentialSuspendPayloadSchema)
.resume(credentialResumeSchema)
.handler(
async (
input: AskCredentialInput,
ctx: InterruptibleToolContext<AskCredentialInput, AskCredentialResume>,
ctx: InterruptibleToolContext<CredentialSuspendPayload, CredentialResumeData>,
): Promise<AskCredentialToolResult> => {
if (deps.isAssistantProxyEnabled()) {
return withNodeCredentialMap(input, {
credentialId: MANAGED_CREDENTIAL_TOKEN,
credentialName: 'Managed by n8n',
});
return withNodeCredentialMap(input, MANAGED_CREDENTIAL_TOKEN, 'Managed by n8n');
}
return await resolveCredentialSelection(input, ctx, deps, (resume) =>
withNodeCredentialMap(input, resume),
);
return await resolveCredentialSelection(input, ctx, deps);
},
)
.build();
@@ -1,34 +0,0 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_LLM_TOOL_NAME,
askLlmInputSchema,
askLlmResumeSchema,
type AskLlmInput,
type AskLlmResume,
} from '@n8n/api-types';
export function buildAskLlmTool(): BuiltTool {
return new Tool(ASK_LLM_TOOL_NAME)
.description(
'Show a model + credential picker card in the chat UI and suspend until the user ' +
'selects a provider, model and credential. ' +
'After resume: set model = "{provider}/{model}" and credential = credentialId ' +
'via write_config or patch_config.',
)
.systemInstruction(
'Never ask the user in plain text to choose, confirm, configure, or change the agent ' +
'main LLM, provider, model, or main LLM credential. If the user needs to make that ' +
'choice, call ask_llm so the picker card is shown.',
)
.input(askLlmInputSchema)
.suspend(askLlmInputSchema)
.resume(askLlmResumeSchema)
.handler(
async (input: AskLlmInput, ctx: InterruptibleToolContext<AskLlmInput, AskLlmResume>) => {
if (ctx.resumeData !== undefined) return ctx.resumeData;
return await ctx.suspend(input);
},
)
.build();
}
@@ -1,42 +0,0 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_QUESTION_TOOL_NAME,
askQuestionInputSchema,
askQuestionResumeSchema,
type AskQuestionInput,
type AskQuestionResume,
} from '@n8n/api-types';
export function buildAskQuestionTool(): BuiltTool {
return new Tool(ASK_QUESTION_TOOL_NAME)
.description(
'Show a question card in the chat UI and suspend until the user answers. Use when ' +
'the request is ambiguous. Pass `options` as the known choices; for an open-ended ' +
'question pass an empty `options` array and the card shows only a freeform input. ' +
'Do NOT add your own "Other" option — the card always includes a freeform field, so ' +
'returned values may include user-entered text. Returns { values: string[] } with ' +
'selected option values and/or freeform text.',
)
.input(askQuestionInputSchema)
.suspend(askQuestionInputSchema)
.resume(askQuestionResumeSchema)
.handler(
async (
input: AskQuestionInput,
ctx: InterruptibleToolContext<AskQuestionInput, AskQuestionResume>,
) => {
if (ctx.resumeData !== undefined) return ctx.resumeData;
// A single single-select option has no real choice — auto-pick it so
// the LLM doesn't render a card the user can only confirm. Multi-select
// questions still render so the user can decide whether to select the
// one option, and open-ended questions (empty options array) still
// suspend to show the freeform-only card.
if (input.options.length === 1 && input.allowMultiple !== true) {
return { values: [input.options[0].value] };
}
return await ctx.suspend(input);
},
)
.build();
}
@@ -0,0 +1,151 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
ASK_QUESTIONS_TOOL_NAME,
questionsResumeSchema,
questionsSuspendPayloadSchema,
type InteractionQuestion,
type QuestionAnswer,
type QuestionsResumeData,
type QuestionsSuspendPayload,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { UserError } from 'n8n-workflow';
import { z } from 'zod';
const askQuestionsQuestionSchema = z.object({
id: z.string().optional().describe('Unique question identifier; defaults to q1, q2, ...'),
question: z.string().describe('The question text to display to the user'),
type: z
.enum(['single', 'multi', 'text'])
.describe('single = pick one option, multi = pick many, text = free-form input'),
options: z
.array(z.string())
.optional()
.describe('Suggested answers (required for single/multi, ignored for text)'),
});
const askQuestionsInputSchema = z.object({
questions: z
.array(askQuestionsQuestionSchema)
.min(1)
.describe('All questions to ask the user, batched into a single card'),
introMessage: z.string().optional().describe('Brief intro text shown above the questions'),
});
type AskQuestionsInput = z.infer<typeof askQuestionsInputSchema>;
/**
* Assign default `q1..qN` ids to questions missing an explicit id, skipping
* any id already claimed by another question's explicit id so two questions
* never end up sharing one (which would make the UI's id-keyed map, and the
* resume payload, conflate their answers).
*/
function withDefaultIds(
questions: Array<z.infer<typeof askQuestionsQuestionSchema>>,
): InteractionQuestion[] {
const explicitIds = questions.map((q) => q.id).filter((id): id is string => id !== undefined);
if (new Set(explicitIds).size !== explicitIds.length) {
throw new UserError('ask_questions: question ids must be unique');
}
const usedIds = new Set(explicitIds);
return questions.map((question, index) => {
if (question.id) return { ...question, id: question.id };
let candidate = index + 1;
let id = `q${candidate}`;
while (usedIds.has(id)) {
candidate++;
id = `q${candidate}`;
}
usedIds.add(id);
return { ...question, id };
});
}
/** A single single-select question with exactly one option has no real choice to make. */
function isAutoResolvable(questions: InteractionQuestion[]): boolean {
return (
questions.length === 1 &&
questions[0].type === 'single' &&
(questions[0].options?.length ?? 0) === 1
);
}
function autoResolvedAnswer(question: InteractionQuestion): QuestionAnswer {
return {
questionId: question.id,
selectedOptions: [question.options![0]],
};
}
/** Merge each answer's question text back in for LLM context — the resume payload only carries ids. */
function enrichAnswers(
answers: QuestionAnswer[],
questions: InteractionQuestion[],
): Array<QuestionAnswer & { question: string }> {
return answers.map((answer) => {
const question = questions.find((q) => q.id === answer.questionId);
return { ...answer, question: question?.question ?? answer.questionId };
});
}
/**
* A dismissal, an empty answer set, or every answer explicitly skipped is
* nothing usable — returns `undefined` in all of those cases, or the usable
* answers otherwise.
*/
function usableAnswers(resumeData: QuestionsResumeData): QuestionAnswer[] | undefined {
if (resumeData.approved === false) return undefined;
if (!resumeData.answers || resumeData.answers.length === 0) return undefined;
if (resumeData.answers.every((answer) => answer.skipped === true)) return undefined;
return resumeData.answers;
}
export function buildAskQuestionsTool(): BuiltTool {
return new Tool(ASK_QUESTIONS_TOOL_NAME)
.description(
'Ask the user one or more questions in a single batched card; the run suspends until ' +
'they respond. ALWAYS use this instead of calling it multiple times when you have more ' +
'than one question — batch them into one call. Questions are single-select, ' +
'multi-select, or free-text. A question is asked at most once — a dismissal or empty ' +
'answer means "proceed without this": assume a sensible default and never re-present ' +
'it. Returns { answered: false } on dismissal, or { answered: true, answers } otherwise. ' +
'A single single-select question with exactly one option auto-resolves to that option ' +
'without showing a card.',
)
.input(askQuestionsInputSchema)
.suspend(questionsSuspendPayloadSchema)
.resume(questionsResumeSchema)
.handler(
async (
input: AskQuestionsInput,
ctx: InterruptibleToolContext<QuestionsSuspendPayload, QuestionsResumeData>,
) => {
const questions = withDefaultIds(input.questions);
if (ctx.resumeData === undefined || ctx.resumeData === null) {
if (isAutoResolvable(questions)) {
const answers = enrichAnswers([autoResolvedAnswer(questions[0])], questions);
return { answered: true, answers };
}
return await ctx.suspend({
requestId: nanoid(),
message: input.introMessage ?? 'The agent builder has questions',
severity: 'info' as const,
inputType: 'questions' as const,
questions,
...(input.introMessage ? { introMessage: input.introMessage } : {}),
});
}
const answers = usableAnswers(ctx.resumeData);
if (!answers) return { answered: false };
return { answered: true, answers: enrichAnswers(answers, questions) };
},
)
.build();
}
@@ -0,0 +1,81 @@
import type { BuiltTool, InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents/tool';
import {
CONFIGURE_CHANNEL_TOOL_NAME,
channelResumeSchema,
channelSuspendPayloadSchema,
type ChannelResumeData,
type ChannelSuspendPayload,
} from '@n8n/api-types';
import { nanoid } from 'nanoid';
import { z } from 'zod';
const configureChannelInputSchema = z.object({
integrationType: z.string().describe('Chat platform type from list_integration_types'),
});
type ConfigureChannelInput = z.infer<typeof configureChannelInputSchema>;
export interface ConfigureChannelToolDeps {
agentId: string;
projectId: string;
/** Wraps `AgentIntegrationPersistenceService.listChatIntegrations()`. */
listChatIntegrationTypes: () => string[];
}
export function buildConfigureChannelTool(deps: ConfigureChannelToolDeps): BuiltTool {
return new Tool(CONFIGURE_CHANNEL_TOOL_NAME)
.description(
'Connect one available chat channel to the target agent. First call ' +
'list_integration_types and pass a returned `type` as `integrationType`; do not infer ' +
'channel names. Shows setup UI in chat where the user creates a new channel credential ' +
'or skips. The setup UI persists the connection, so use this for channel credentials ' +
'instead of the credentials tool or config writes. Returns { connected: boolean }; if ' +
'false, continue without the channel and do not re-prompt.',
)
.input(configureChannelInputSchema)
.suspend(channelSuspendPayloadSchema)
.resume(channelResumeSchema)
.handler(
async (
{ integrationType }: ConfigureChannelInput,
ctx: InterruptibleToolContext<ChannelSuspendPayload, ChannelResumeData>,
) => {
// Resumed — the user connected (approved) or skipped (dismissed). Handled
// before the integration-catalog validation below: a run rebuilt from a
// checkpoint after a process restart may see a different (or empty)
// catalog than the original call, but the setup card already persisted
// (or skipped) the connection, so the resume leg only reports the outcome.
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
return { connected: ctx.resumeData.approved };
}
const availableTypes = deps.listChatIntegrationTypes();
if (!availableTypes.includes(integrationType)) {
const availableMessage = availableTypes.length
? ` Available: ${availableTypes.join(', ')}.`
: ' No chat channels are currently available.';
return {
ok: false as const,
errors: [
{
message:
`Unsupported chat channel "${integrationType}". Call list_integration_types ` +
'and choose a returned type.' +
availableMessage,
},
],
};
}
return await ctx.suspend({
requestId: nanoid(),
message: `Set up the ${integrationType} channel`,
severity: 'info' as const,
channelConfig: { integrationType, agentId: deps.agentId },
projectId: deps.projectId,
});
},
)
.build();
}
@@ -1,4 +1,4 @@
export { buildAskCredentialTool, buildAskEmbeddingCredentialTool } from './ask-credential.tool';
export { buildAskLlmTool } from './ask-llm.tool';
export { buildAskQuestionTool } from './ask-question.tool';
export { buildAskQuestionsTool } from './ask-questions.tool';
export { buildConfigureChannelTool } from './configure-channel.tool';
export { buildResolveLlmTool } from './resolve-llm.tool';
@@ -1,7 +1,7 @@
/**
* Canonical "if you have one of THIS credential type, this is the LLM provider
* + model the builder may select when auto-resolving." Used by the ask_llm tool
* when there's exactly one LLM-provider credential available.
* + model the builder may select when auto-resolving." Used by the resolve_llm
* tool when there's exactly one LLM-provider credential available.
*
* Provider strings match the provider IDs used by `@n8n/agents`'s
* `.model(provider, model)` call.
@@ -97,8 +97,8 @@ export function buildResolveLlmTool(deps: ResolveLlmToolDeps): BuiltTool {
'If provider is given, resolves only that provider; if model is omitted, uses the ' +
'provider default model. For "Anthropic via OpenRouter", pass provider="openrouter" ' +
'and omit model unless the user named a concrete OpenRouter model id. Returns ok=false ' +
'when credentials are missing, unsupported, or ambiguous; use ask_llm only when the ' +
'user must choose.',
'when credentials are missing, unsupported, or ambiguous; use ask_questions to let the ' +
'user choose, then call resolve_llm again with the choice.',
)
.input(
z.object({
@@ -30,7 +30,7 @@ ${getSchemaReferenceSection()}
- Keep each feature in the schema path where it belongs.
- Preserve unrelated existing config unless the user asked to change it.
- Never write placeholder instructions, tool descriptions, or skill descriptions.
- Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
- Never copy credential IDs from \`list_credentials\`; use \`resolve_llm\` or \`ask_credential\`.
- Valid provider tool keys are complete provider tool IDs documented in the Tool Guidance section.
- \`providerTools\` keys must be complete provider tool IDs from the valid key list.
@@ -50,7 +50,7 @@ export function getConfigRulesSection(): string {
#### Agent Config Rules
- \`model\` must be "provider/model-name".
- \`credential\` must be the id returned by \`resolve_llm\` or \`ask_llm\`.
- \`credential\` must be the id returned by \`resolve_llm\`.
- Fresh agents must include
\`memory: { "enabled": true, "storage": "n8n" }\`
unless the user explicitly asks to disable memory.
@@ -58,7 +58,7 @@ export function getConfigRulesSection(): string {
- \`memory.episodicMemory\` requires \`ask_embedding_credential\` with
\`credentialType: "openAiApi"\`; use its returned \`credentialId\` value.
- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`;
use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`.
use only credential IDs returned by \`resolve_llm\` or \`ask_credential\`.
- Sub-agent configuration lives at top level under \`subAgents\`. Load
\`agent-builder-sub-agents\` before adding refs or changing
\`subAgents.maxChildren\`.
@@ -1,7 +1,7 @@
export function getLlmSelectionPrompt(modelRecommendationsSection: string | null): string {
const recommendationGuidance = modelRecommendationsSection
? `\n\n${modelRecommendationsSection}`
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Call ask_llm when the user needs model guidance or choice.';
: '\n\nNo Recommended LLM models section is available; do not recommend or name current, best, latest, or fallback model IDs from memory. Ask via `ask_questions` when the user needs model guidance or choice.';
return `\
## LLM Selection Guidance
@@ -12,17 +12,17 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
### Workflow
1. Use \`resolve_llm\` when the request contains enough provider/model detail, otherwise call \`ask_llm\`.
1. Use \`resolve_llm\` when the request contains enough provider/model detail, otherwise ask via \`ask_questions\` and call \`resolve_llm\` with the answer.
2. If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`.
3. If the user asks to pick, change, confirm, or configure a model or main credential, call \`ask_llm\`; do not ask in prose.
4. If \`resolve_llm\` reports missing or ambiguous credentials/provider, call \`ask_llm\`.
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible returned model value or call \`ask_llm\`.
3. If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose.
4. If \`resolve_llm\` reports missing or ambiguous credentials/provider, ask via \`ask_questions\` then retry \`resolve_llm\` with the answer.
5. If \`resolve_llm\` reports \`unknown_model\`, retry with a plausible returned model value or ask via \`ask_questions\`.
### Rules
- Fresh agents need a resolved \`model\` and \`credential\` before config is written.
- Explicit provider/model requests go to \`resolve_llm\` first.
- If the user asks to pick, change, confirm, or configure a model or main credential, call \`ask_llm\`; do not ask in prose.
- If the user asks to pick, change, confirm, or configure a model or main credential, ask via \`ask_questions\`; do not ask in prose.
- If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`.
- Only OpenAI and Anthropic models support native web search. Use native web
search by default for those providers only, and only for
@@ -39,15 +39,15 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
\`provider: "searxng"\`.
- If the user explicitly asks for Brave or SearXNG, keep that provider even
when the selected model also supports native search.
- If \`resolve_llm\` reports missing or ambiguous credentials/provider, call \`ask_llm\`.
- If it reports \`unknown_model\`, retry with a plausible returned model value or call \`ask_llm\`.
- If \`resolve_llm\` reports missing or ambiguous credentials/provider, ask via \`ask_questions\` then retry \`resolve_llm\`.
- If it reports \`unknown_model\`, retry with a plausible returned model value or ask via \`ask_questions\`.
- For "Anthropic via OpenRouter", pass \`provider: "openrouter"\`; if the user names a routed model, pass the routed id without adding another provider prefix.
- Prefer a provider the user already has credentials for when choosing from recommendations.
- Never copy main LLM credential IDs from \`list_credentials\`.
### Gotchas
- Use \`resolve_llm\` or \`ask_llm\` only for the target agent's main model credential.
- Use \`resolve_llm\` only for the target agent's main model credential.
- Use \`ask_credential\` for node tools, integrations, and Episodic Memory.
- For OpenRouter, \`provider\` is \`"openrouter"\`; the model can be a routed id such as \`anthropic/...\`.
- Model changes must not silently replace existing Brave or SearXNG web search with native search.
@@ -56,6 +56,6 @@ Use this to resolve the target agent's main \`model\` and \`credential\`.
### Verify
- The persisted \`model\` is in \`provider/model\` form.
- The persisted \`credential\` came from \`resolve_llm\` or \`ask_llm\`.
- The persisted \`credential\` came from \`resolve_llm\`.
- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.${recommendationGuidance}`;
}
@@ -31,7 +31,7 @@ separate user-facing memory product.
- Supported observational memory tuning fields: \`enabled\`, \`observerModel\`, \`reflectorModel\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`.
- Memory worker model fields must use object shape: \`{ "model": "provider/model-name", "credential": "<credentialId>" }\`.
- Only set \`observerModel\`, \`reflectorModel\`, \`extractorModel\`, or \`episodicMemory.reflectorModel\` when the user explicitly asks to use a specific model for memory work.
- Use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\` for memory worker model fields. Do not invent IDs or copy a main-model credential unless one of those tools returned it for that worker model provider.
- Use only credential IDs returned by \`resolve_llm\` or \`ask_credential\` for memory worker model fields. Do not invent IDs or copy a main-model credential unless one of those tools returned it for that worker model provider.
### Episodic Memory
@@ -0,0 +1,23 @@
import { CONFIGURE_CHANNEL_TOOL_NAME } from '@n8n/api-types';
import { getBuilderRuntimeSkills } from '../index';
describe('getBuilderRuntimeSkills', () => {
it('includes the integrations skill by default', () => {
const skills = getBuilderRuntimeSkills();
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
});
it('omits the integrations skill when configure_channel is excluded (e.g. instance-AI sub-agent sessions)', () => {
const skills = getBuilderRuntimeSkills([CONFIGURE_CHANNEL_TOOL_NAME]);
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(false);
});
it('keeps the integrations skill when other tools are excluded', () => {
const skills = getBuilderRuntimeSkills(['ask_questions']);
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
});
});
@@ -1,4 +1,5 @@
import type { RuntimeSkill } from '@n8n/agents';
import { CONFIGURE_CHANNEL_TOOL_NAME } from '@n8n/api-types';
import { integrationsSkill } from './integrations.skill';
import { mcpSkill } from './mcp.skill';
@@ -7,7 +8,14 @@ import { subAgentsSkill } from './sub-agents.skill';
import { targetSkillsSkill } from './target-skills.skill';
import { targetTasksSkill } from './target-tasks.skill';
export function getBuilderRuntimeSkills(): RuntimeSkill[] {
/**
* `excludeTools` mirrors `BuilderSessionOptions.excludeTools` (e.g. the
* instance-AI sub-agent session, which has no chat-card UI and excludes
* `configure_channel`). The integrations skill's whole instructions revolve
* around calling `configure_channel`, so it's dropped rather than left in to
* instruct a tool call that would fail in that session (see AGENT-353).
*/
export function getBuilderRuntimeSkills(excludeTools: string[] = []): RuntimeSkill[] {
const skills: RuntimeSkill[] = [
integrationsSkill(),
mcpSkill(),
@@ -21,5 +29,9 @@ export function getBuilderRuntimeSkills(): RuntimeSkill[] {
// researchSkill(),
];
if (excludeTools.includes(CONFIGURE_CHANNEL_TOOL_NAME)) {
return skills.filter((skill) => skill.id !== 'agent-builder-integrations');
}
return skills;
}
@@ -6,10 +6,15 @@ export function integrationsSkill(): RuntimeSkill {
name: 'Agent Builder Integrations',
description:
'Use when deciding whether Slack, Linear, Telegram, or another external platform should be a target-agent chat integration/trigger versus a node tool, and when adding or changing chat integrations; not for built-in Build chat or Preview chat behavior.',
recommendedTools: ['list_integration_types', 'ask_credential', 'read_config', 'patch_config'],
recommendedTools: [
'list_integration_types',
'configure_channel',
'read_config',
'patch_config',
],
allowedTools: [
'list_integration_types',
'ask_credential',
'configure_channel',
'read_config',
'patch_config',
'write_config',
@@ -59,13 +64,16 @@ The \`integrations\` array controls how the target agent is triggered.
- Call \`list_integration_types\` first.
- Read the returned \`capabilities\`, \`useIntegrationWhen\`, and
\`useNodeToolWhen\` fields before deciding to add an integration.
- Pick one returned \`credentialTypes\` entry and pass it to \`ask_credential\`.
- Persist only \`type\` and \`credentialId\`; never invent credential IDs or names.
- Pick one returned \`type\` and pass it to \`configure_channel\` as
\`integrationType\`. ALWAYS use \`configure_channel\` for chat-channel
credentials — never \`ask_credential\` or a raw config write. The setup UI it
shows creates and persists the credential/connection itself; do not follow up
with \`patch_config\`/\`write_config\` to write the credential.
- Preserve existing chat integrations unless the user asked to remove them.
## Gotchas
- Chat integration credential types must come from \`list_integration_types\`.
- Chat integration types must come from \`list_integration_types\`.
- Do not add a Linear integration just because the agent needs Linear issue
CRUD. Use Linear node tools unless Linear itself is the chat/trigger context.
- For recurring or scheduled runs, create a task (\`create_task\`), not an
@@ -73,7 +81,8 @@ The \`integrations\` array controls how the target agent is triggered.
## Verify
- Connected chat integrations use a credential id returned by \`ask_credential\`.
- Connected chat integrations were set up through \`configure_channel\`, not
\`ask_credential\` or a manual config write.
- The chosen integration matches \`useIntegrationWhen\`; otherwise use node or
workflow tools.
- The final \`integrations\` array keeps unrelated integrations intact.`,
@@ -1,5 +1,5 @@
import type { RuntimeSkill } from '@n8n/agents';
import { ASK_QUESTION_TOOL_NAME, McpServerConfigSchema } from '@n8n/api-types';
import { ASK_QUESTIONS_TOOL_NAME, McpServerConfigSchema } from '@n8n/api-types';
import type { JSONSchema7 } from 'json-schema';
import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -26,7 +26,7 @@ export function mcpSkill(): RuntimeSkill {
'search_mcp_servers',
'ask_credential',
'verify_mcp_server',
'ask_question',
'ask_questions',
'read_config',
'patch_config',
'write_config',
@@ -103,7 +103,7 @@ by \`search_mcp_servers\`.
For custom MCP servers, if credential type is unknown, ask the user which
credential type to use (OAuth2, Bearer Token, Header Auth, Multiple Headers
Auth, or None) via \`${ASK_QUESTION_TOOL_NAME}\`. Then map to:
Auth, or None) via \`${ASK_QUESTIONS_TOOL_NAME}\`. Then map to:
- \`bearerAuth\` -> \`ask_credential\` with \`credentialType: "httpBearerAuth"\`
- \`headerAuth\` -> \`ask_credential\` with \`credentialType: "httpHeaderAuth"\`
@@ -19,7 +19,7 @@ export function resourceLocatorsSkill(): RuntimeSkill {
'get_node_types',
'ask_credential',
'get_resource_locator_options',
'ask_question',
'ask_questions',
'read_config',
'patch_config',
'write_config',
@@ -62,7 +62,7 @@ locator values that the target agent cannot reliably guess at runtime.
- current \`nodeParameters\`
- returned \`credentials\`, when available
- \`filter\` when the user named a specific team, channel, project, or object
6. If results are ambiguous, use \`ask_question\` with the returned option names.
6. If results are ambiguous, use \`ask_questions\` with the returned option names.
If there are many pages, retry with \`paginationToken\` or a narrower
\`filter\`.
7. Write the selected result's \`parameterValue\` exactly into
@@ -11,10 +11,10 @@ export function subAgentsSkill(): RuntimeSkill {
name: 'Agent Builder Sub-Agents',
description:
'Use when configuring inline or saved sub-agent delegation for the target agent, selecting published same-project sub-agents, or changing subAgents.maxChildren.',
recommendedTools: ['list_sub_agents', 'ask_question', 'read_config', 'patch_config'],
recommendedTools: ['list_sub_agents', 'ask_questions', 'read_config', 'patch_config'],
allowedTools: [
'list_sub_agents',
'ask_question',
'ask_questions',
'read_config',
'patch_config',
'write_config',
@@ -55,8 +55,9 @@ subagent.
1. Call \`list_sub_agents\` to discover published same-project agents that can be
added. Do not write agent ids from memory, prose, or user-entered free text.
2. If published agents are available and the user has not named exact agents,
call \`ask_question\` with \`allowMultiple: true\`. Use each option's
\`value\` as the returned \`agentId\`.
call \`ask_questions\` with one \`type: "multi"\` question whose \`options\`
are the returned agent names. Map each selected option back to the
matching \`agentId\` from the \`list_sub_agents\` result.
3. If no published agents are available, do not configure saved subagents.
Inline delegation still works without saved-agent refs.
4. Determine the parent-owned routing guidance for each selected saved
@@ -72,7 +73,8 @@ Example patch flow:
1. \`list_sub_agents()\`.
2. If it returns one or more agents and the user has not named exact ones, call
\`ask_question({ allowMultiple: true, ... })\` with those agents as options.
\`ask_questions({ questions: [{ type: "multi", ... }] })\` with those agents
as options.
3. If the user's request does not make the routing rule clear, ask when each
selected saved subagent should be used.
4. \`read_config()\`.
@@ -12,8 +12,8 @@ export function targetSkillsSkill(): RuntimeSkill {
name: 'Agent Builder Target Skills',
description:
'Use when creating reusable target-agent skills, playbooks, policies, style guides, or domain instructions with create_skill that should load only for relevant future requests; not for builder guidance or one-off instructions.',
recommendedTools: ['create_skill', 'ask_question', 'read_config', 'patch_config'],
allowedTools: ['create_skill', 'ask_question', 'read_config', 'patch_config', 'write_config'],
recommendedTools: ['create_skill', 'ask_questions', 'read_config', 'patch_config'],
allowedTools: ['create_skill', 'ask_questions', 'read_config', 'patch_config', 'write_config'],
instructions: `\
## Purpose
@@ -49,8 +49,9 @@ Do NOT call \`create_skill\` until you have enough concrete domain detail to wri
a genuinely useful skill: a specific routing description and a body whose
applicable sections are filled with real content (the actual steps, rules,
examples, and edge cases). If any of that is missing, ask the user clarifying
questions (use \`ask_question\` — discrete options for choices, or empty options
for open-ended) until you can write it. Never create a placeholder or vague skill.
questions (use \`ask_questions\`, batching multiple questions into one call —
discrete options for choices, or \`type: "text"\` for open-ended) until you can
write it. Never create a placeholder or vague skill.
## Workflow
@@ -8,10 +8,10 @@ export function targetTasksSkill(): RuntimeSkill {
name: 'Agent Builder Target Tasks',
description:
'Use when the user wants the target agent to run something on a recurring schedule (a "task"): a daily/weekly/hourly objective the agent carries out on its own with create_task. Not for one-off requests, chat/event triggers, or config/tool/skill/model edits.',
recommendedTools: ['create_task', 'ask_question', 'read_config', 'patch_config'],
recommendedTools: ['create_task', 'ask_questions', 'read_config', 'patch_config'],
allowedTools: [
'create_task',
'ask_question',
'ask_questions',
'read_config',
'patch_config',
'write_config',
@@ -56,9 +56,9 @@ Do NOT call \`create_task\` until BOTH of these are true:
2. The schedule is concrete — how often and at what time it should run.
If any section would be empty or a guess, ask the user clarifying questions (use
\`ask_question\` — discrete options for choices, or empty options for open-ended)
until you can complete the whole template and pin down the cadence. Never create a placeholder
or "refine-it-later" task.
\`ask_questions\`, batching multiple questions into one call — discrete options for
choices, or \`type: "text"\` for open-ended) until you can complete the whole
template and pin down the cadence. Never create a placeholder or "refine-it-later" task.
## Workflow
@@ -1,9 +1,9 @@
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
import {
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
} from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -28,8 +28,8 @@ import type { BuilderSessionOptions } from './builder/agents-builder.service';
* turn and report open questions as reply text instead of suspending.
*/
export const NON_INTERACTIVE_EXCLUDED_TOOL_NAMES: string[] = [
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
];
@@ -37,7 +37,7 @@ export const NON_INTERACTIVE_EXCLUDED_TOOL_NAMES: string[] = [
/** Prompt addendum for sub-agent runs; exported for tests. */
export const INSTANCE_AI_BUILDER_ADDENDUM = `## Instance AI session rules
You are running as a sub-agent inside n8n's instance AI chat. You CANNOT ask the user anything mid-turn: the interactive tools (ask_llm, ask_question, ask_credential, ask_embedding_credential) are not available in this session.
You are running as a sub-agent inside n8n's instance AI chat. You CANNOT ask the user anything mid-turn: the interactive tools (ask_questions, ask_credential, ask_embedding_credential, configure_channel) are not available in this session.
- Never wait for user input. Complete every turn with your best result.
- Make sensible default choices where the instructions leave room, and state the choices you made in your reply.
@@ -4829,7 +4829,6 @@
"agents.modelSelector.freeCredits.label": "Use free OpenAI credits",
"agents.modelSelector.freeCredits.badge": "free credits",
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
"agents.askLlm.chooseModel": "Choose a model",
"agents.builder.unconfigured.title": "Set up the agent builder",
"agents.builder.unconfigured.description.admin": "Choose a provider and credential so the agent builder can help you design agents.",
"agents.builder.unconfigured.description.nonAdmin": "Ask an instance admin to configure the agent builder before you can build agents here.",
@@ -6739,6 +6738,9 @@
"agents.chat.misconfigured.openBuild": "Finish setup in Build",
"agents.chat.misconfigured.dismiss": "Dismiss",
"agents.chat.askCredential.skip": "Skip",
"agents.chat.askCredential.skipped": "Skipped",
"agents.chat.askQuestions.skipped": "Skipped",
"agents.chat.configureChannel.skipped": "Skipped",
"agents.chat.askCredential.managed": "Managed by n8n",
"agents.chat.toolNames.webSearch": "Web search",
"agents.chat.toolNames.findFile": "Find file",
@@ -6771,11 +6773,6 @@
"agents.chat.writeTodos.hint.expectedOutput": "Expected output",
"agents.chat.toolStep.waitingForInput": "Waiting for your input",
"agents.chat.waitingExternal": "Waiting for a response in {platform}…",
"agents.chat.askQuestion.otherLabel": "Other",
"agents.chat.askQuestion.otherPlaceholder": "Type another answer",
"agents.chat.askQuestion.answerLabel": "Your answer",
"agents.chat.askQuestion.answerPlaceholder": "Type your answer",
"agents.chat.askQuestion.submit": "Submit",
"agents.backToWorkflow": "Back to workflow",
"agents.heading": "Agents",
"agents.list.published": "Published",
@@ -80,6 +80,7 @@ function mountSection(
config: AgentJsonConfig | null = null,
taskRefs: AgentJsonTaskConfig[] = [],
projectAgents: AgentResource[] = [],
extraProps: Record<string, unknown> = {},
) {
projectAgentsListRef.value = projectAgents;
@@ -94,6 +95,7 @@ function mountSection(
agentId: 'agent-id',
isPublished: false,
taskRefs,
...extraProps,
},
global: {
stubs: {
@@ -113,6 +115,12 @@ function mountSection(
N8nIcon: { template: '<span />' },
N8nText: { template: '<span><slot /></span>' },
N8nTooltip: { template: '<span><slot /></span>' },
AgentChannelModal: {
name: 'AgentChannelModal',
props: ['simpleSetup'],
template:
'<div data-testid="agent-channel-modal-stub" :data-simple-setup="simpleSetup" />',
},
},
},
});
@@ -627,6 +635,30 @@ describe('AgentCapabilitiesSection', () => {
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(false);
});
describe('simpleChannelSetup', () => {
it('does not force simple setup on the channel modal by default', async () => {
const wrapper = mountSection([]);
await wrapper.find('[data-testid="agent-capabilities-add-channel"]').trigger('click');
await flushPromises();
const modal = wrapper.find('[data-testid="agent-channel-modal-stub"]');
expect(modal.exists()).toBe(true);
expect(modal.attributes('data-simple-setup')).toBe('false');
});
it('forwards simpleChannelSetup to the channel modal as simple-setup', async () => {
const wrapper = mountSection([], {}, null, [], [], { simpleChannelSetup: true });
await wrapper.find('[data-testid="agent-capabilities-add-channel"]').trigger('click');
await flushPromises();
const modal = wrapper.find('[data-testid="agent-channel-modal-stub"]');
expect(modal.exists()).toBe(true);
expect(modal.attributes('data-simple-setup')).toBe('true');
});
});
describe('sections allowlist', () => {
it('renders every section by default', () => {
const wrapper = mountSection([]);
@@ -0,0 +1,137 @@
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import AgentChannelModal from '../components/AgentChannelModal.vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
}),
}));
const catalog = ref([
{ type: 'slack', label: 'Slack', icon: 'zap' },
{ type: 'linear', label: 'Linear', icon: 'zap' },
]);
vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
useAgentIntegrationsCatalog: () => ({
catalog,
ensureLoaded: vi.fn().mockResolvedValue(catalog.value),
}),
}));
vi.mock('../composables/useAgentIntegrationStatus', () => ({
useAgentIntegrationStatus: () => ({
fetchStatus: vi.fn().mockResolvedValue(undefined),
connectedCredentials: ref({}),
integrationSettings: ref({}),
loadingMap: ref({}),
errorMessages: ref({}),
errorIsConflict: ref({}),
isConnected: () => false,
connect: vi.fn(),
disconnect: vi.fn(),
}),
}));
vi.mock('../composables/useAgentChannelSetup', () => ({
useAgentChannelSetup: () => ({
channelSetupRef: ref(),
selectedCredentials: ref({}),
credentialsLoading: ref(false),
credentialPermissions: ref({}),
credentialModalOpen: ref(false),
getChannelCredentialId: () => '',
getCredentials: () => [],
loadChannelState: vi.fn().mockResolvedValue(undefined),
createCredential: vi.fn(),
editCredential: vi.fn(),
setupSlackApp: vi.fn(),
}),
}));
function mountModal(props: Record<string, unknown>) {
return mount(AgentChannelModal, {
props: {
open: true,
agentId: 'agent-1',
projectId: 'project-1',
view: 'linear_setup',
connectedChannels: [],
isPublished: false,
...props,
},
global: {
stubs: {
// The N8nDialog family's SFCs don't set an explicit `defineOptions({ name })`,
// so Vue infers the component name from the *filename* (Dialog.vue,
// DialogHeader.vue, ...) rather than the `N8n`-prefixed name they're imported
// under -- stubs must be keyed by that inferred name to be picked up.
Dialog: { props: ['open'], template: '<div v-if="open"><slot /></div>' },
DialogHeader: { template: '<div><slot /></div>' },
DialogTitle: { template: '<h3><slot /></h3>' },
DialogFooter: { template: '<div><slot /></div>' },
N8nButton: { template: '<button @click="$emit(\'click\')"><slot /></button>' },
N8nIconButton: { template: '<button @click="$emit(\'click\')"><slot /></button>' },
N8nIcon: { template: '<i />' },
N8nText: { template: '<span><slot /></span>' },
AgentChannelListItem: { template: '<li data-testid="channel-list-item" />' },
AgentChannelSlackSetup: {
props: ['forceNewCredential', 'setupMode', 'mode'],
template:
'<div data-testid="slack-setup" :data-mode="mode" :data-force-new-credential="forceNewCredential" :data-setup-mode="setupMode" />',
},
AgentChannelLinearSetup: {
props: ['forceNewCredential', 'mode'],
template:
'<div data-testid="linear-setup" :data-mode="mode" :data-force-new-credential="forceNewCredential" />',
},
AgentChannelTelegramSetup: {
props: ['forceNewCredential', 'mode'],
template:
'<div data-testid="telegram-setup" :data-mode="mode" :data-force-new-credential="forceNewCredential" />',
},
},
},
});
}
describe('AgentChannelModal', () => {
it('does not force a new credential or simple setup by default', () => {
const wrapper = mountModal({ view: 'linear_setup' });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-force-new-credential')).toBe('false');
});
it('forces a new credential and simple setup mode on the Slack setup child when simpleSetup is set', () => {
const wrapper = mountModal({ view: 'slack_setup', simpleSetup: true });
const slackSetup = wrapper.find('[data-testid="slack-setup"]');
expect(slackSetup.attributes('data-force-new-credential')).toBe('true');
expect(slackSetup.attributes('data-setup-mode')).toBe('simple');
});
it('forces a new credential on non-Slack setup children when simpleSetup is set', () => {
const wrapper = mountModal({ view: 'linear_setup', simpleSetup: true });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-force-new-credential')).toBe('true');
});
it('redirects a channel edit view to the simple setup view instead of the advanced edit UI', () => {
const wrapper = mountModal({ view: 'linear_edit', simpleSetup: true });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-mode')).toBe('setup');
});
it('keeps the advanced edit view when simpleSetup is not set', () => {
const wrapper = mountModal({ view: 'linear_edit' });
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-mode')).toBe('edit');
});
});
@@ -370,11 +370,20 @@ describe('AgentChatMessageList', () => {
role: 'assistant',
content: 'Thanks!',
interactive: {
toolName: 'ask_question',
toolName: 'ask_questions',
toolCallId: 'tc-q',
resolvedAt: 1,
input: { question: 'Pick one', options: [{ label: 'A', value: 'a' }] },
resolvedValue: { values: ['a'] },
input: {
requestId: 'req-1',
message: 'Pick one',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['a'] }],
},
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['a'] }],
},
},
status: 'success',
} satisfies ChatMessage,
@@ -3,8 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils';
import { computed, h, ref } from 'vue';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
type InteractiveToolName,
} from '@n8n/api-types';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
@@ -101,7 +100,7 @@ describe('AgentChatPanel', () => {
}
function openInteractiveMessage(
toolName: InteractiveToolName = ASK_QUESTION_TOOL_NAME,
toolName: InteractiveToolName = ASK_QUESTIONS_TOOL_NAME,
): ChatMessage {
return {
id: 'assistant-1',
@@ -109,32 +108,37 @@ describe('AgentChatPanel', () => {
content: '',
status: 'awaitingUser',
interactive:
toolName === ASK_QUESTION_TOOL_NAME
toolName === ASK_QUESTIONS_TOOL_NAME
? {
toolName: ASK_QUESTION_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: {
question: 'Pick one',
options: [{ label: 'Slack', value: 'slack' }],
requestId: 'req-1',
message: 'Pick one',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['slack'] }],
},
}
: toolName === ASK_LLM_TOOL_NAME
? {
toolName: ASK_LLM_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: { purpose: 'Choose a model' },
}
: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: {
purpose: 'Choose Slack credentials',
credentialType: 'slackApi',
},
: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: {
requestId: 'req-1',
message: 'Choose Slack credentials',
severity: 'info',
credentialRequests: [
{
credentialType: 'slackApi',
reason: 'Choose Slack credentials',
existingCredentials: [],
},
],
credentialFlow: { stage: 'generic' },
},
},
};
}
@@ -290,14 +294,20 @@ describe('AgentChatPanel', () => {
...openInteractiveMessage(),
status: 'success',
interactive: {
toolName: ASK_QUESTION_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-1',
resolvedAt: 1,
input: {
question: 'Pick one',
options: [{ label: 'Slack', value: 'slack' }],
requestId: 'req-1',
message: 'Pick one',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['slack'] }],
},
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['slack'] }],
},
resolvedValue: { values: ['slack'] },
},
},
];
@@ -349,14 +359,20 @@ describe('AgentChatPanel', () => {
...openInteractiveMessage(),
status: 'success',
interactive: {
toolName: ASK_QUESTION_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-1',
resolvedAt: 1,
input: {
question: 'Pick one',
options: [{ label: 'Slack', value: 'slack' }],
requestId: 'req-1',
message: 'Pick one',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['slack'] }],
},
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['slack'] }],
},
resolvedValue: { values: ['slack'] },
},
},
];
@@ -368,7 +384,7 @@ describe('AgentChatPanel', () => {
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
});
it.each([ASK_LLM_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])(
it.each([ASK_QUESTIONS_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])(
'enables chat input while %s is unresolved (cancel-and-steer mode)',
(toolName) => {
messagesMock.value = [openInteractiveMessage(toolName)];
@@ -6,10 +6,12 @@ import { ChatHubToolContextKey } from '@/app/constants/injectionKeys';
const credentialsById: Record<string, { id: string; name: string }> = {};
const getCredentialById = vi.fn((id: string) => credentialsById[id]);
const getCredentialTypeByName = vi.fn(() => undefined);
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
getCredentialById,
getCredentialTypeByName,
}),
}));
@@ -78,13 +80,19 @@ const NodeCredentialsStub = {
`,
};
const CredentialIconStub = {
template: '<i data-testid="credential-icon" />',
props: ['credentialTypeName', 'size'],
};
import AskCredentialCard from '../components/interactive/AskCredentialCard.vue';
const baseProps = {
purpose: 'Slack credential',
credentialType: 'slackApi',
credentialRequests: [
{ credentialType: 'slackApi', reason: 'Slack credential', existingCredentials: [] },
],
message: 'Slack credential',
projectId: 'p1',
agentId: 'a1',
};
function mountCard(props: Record<string, unknown> = {}) {
@@ -93,12 +101,14 @@ function mountCard(props: Record<string, unknown> = {}) {
global: {
stubs: {
NodeCredentials: NodeCredentialsStub,
CredentialIcon: CredentialIconStub,
N8nButton: {
props: ['disabled', 'type', 'size', 'variant'],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
},
N8nIcon: { template: '<i v-bind="$attrs"></i>', props: ['icon', 'size', 'color'] },
N8nCard: { template: '<section><slot/></section>' },
N8nText: { template: '<span><slot/></span>', props: ['size', 'bold', 'color', 'tag'] },
},
},
@@ -128,7 +138,7 @@ describe('AskCredentialCard', () => {
expect(stub.attributes('data-readonly')).toBe('false');
});
it('emits skipped: true when Skip is pressed', async () => {
it('emits { skipped: true } when Skip is pressed', async () => {
const wrapper = mountCard();
await flushPromises();
@@ -139,16 +149,14 @@ describe('AskCredentialCard', () => {
expect(emitted[0][0]).toEqual({ skipped: true });
});
it('emits the chosen credential as soon as a credential is picked', async () => {
it('emits the chosen credential as a `credentials` map as soon as it is picked', async () => {
const wrapper = mountCard();
await flushPromises();
expect(wrapper.find('[data-testid="ask-credential-confirm"]').exists()).toBe(false);
await wrapper.find('[data-testid="stub-pick-credential"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ credentialId: 'cred-1', credentialName: 'Acme Slack' });
expect(emitted[0][0]).toEqual({ credentials: { slackApi: 'cred-1' } });
});
it('does not emit when NodeCredentials emits an empty credentials map', async () => {
@@ -167,29 +175,31 @@ describe('AskCredentialCard', () => {
expect(wrapper.find('[data-testid="ask-credential-skip"]').exists()).toBe(false);
});
it('forwards the disabled flag to NodeCredentials as readonly', async () => {
const wrapper = mountCard({ disabled: true });
await flushPromises();
const stub = wrapper.find('[data-testid="node-credentials-stub"]');
expect(stub.attributes('data-readonly')).toBe('true');
});
it('renders the resolved credential name when given a resolvedValue with credentialName', async () => {
it('replaces the live picker with a resolved summary containing the credential name when disabled', async () => {
const wrapper = mountCard({
disabled: true,
resolvedValue: { credentialId: 'cred-9', credentialName: 'Picked Slack' },
});
await flushPromises();
expect(wrapper.find('[data-testid="node-credentials-stub"]').exists()).toBe(false);
expect(wrapper.text()).toContain('Picked Slack');
});
it('renders the resolved credential name from a `credentials` map resume value', async () => {
const wrapper = mountCard({
disabled: true,
resolvedValue: { credentials: { slackApi: 'cred-1' } },
});
await flushPromises();
expect(wrapper.text()).toContain('Acme Slack');
});
it('renders the "Skipped" label when the resolvedValue is { skipped: true }', async () => {
const wrapper = mountCard({
disabled: true,
resolvedValue: { skipped: true },
});
await flushPromises();
expect(wrapper.text()).toContain('Skipped');
expect(wrapper.text()).toContain('agents.chat.askCredential.skipped');
});
});
@@ -1,181 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-explicit-any -- test-only */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { ref } from 'vue';
const selectorState = vi.hoisted(() => ({
selection: { provider: 'anthropic', model: 'claude-sonnet-4-5' },
}));
const credentialsByProvider = ref<Record<string, string> | undefined>({ anthropic: 'cred-1' });
const selectCredential = vi.fn();
const ensureLoaded = vi.fn();
const getModelsForPicker = vi.fn(() => ({
anthropic: { models: [] },
}));
const isLoading = ref(false);
const personalProject = ref<{ id: string } | null>({ id: 'p1' });
const allCredentials = ref<Array<{ id: string; name: string }>>([
{ id: 'cred-1', name: 'My Anthropic' },
]);
const currentUserId = ref('user-1');
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: () => ({
get currentUserId() {
return currentUserId.value;
},
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
get allCredentials() {
return allCredentials.value;
},
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => ({
get personalProject() {
return personalProject.value;
},
}),
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, useRoute: () => ({ params: {} }) };
});
vi.mock('../composables/useModelCatalog', () => ({
useModelCatalog: () => ({
ensureLoaded,
getModelsForPicker,
isLoading,
}),
}));
vi.mock('../composables/useAgentModelCredentials', () => ({
useAgentModelCredentials: () => ({ credentialsByProvider, selectCredential }),
}));
vi.mock('../components/AgentModelSelector.vue', () => ({
default: {
name: 'AgentModelSelector',
props: [
'selectedModel',
'credentials',
'modelsByProvider',
'isLoading',
'warnMissingCredentials',
],
emits: ['change', 'select-credential'],
setup(_props: unknown, { emit }: { emit: (event: string, payload: unknown) => void }) {
return {
selectModel: () => emit('change', selectorState.selection),
};
},
template: '<div data-testid="model-selector" @click="selectModel" />',
},
}));
import AskLlmCard from '../components/interactive/AskLlmCard.vue';
function mountCard(props: Record<string, unknown> = {}) {
return mount(AskLlmCard, {
props,
global: {
stubs: {
N8nText: { template: '<span><slot/></span>', props: ['size', 'bold', 'color', 'tag'] },
N8nIcon: { template: '<i v-bind="$attrs"></i>', props: ['icon', 'size', 'color'] },
},
},
});
}
beforeEach(() => {
vi.clearAllMocks();
selectorState.selection = { provider: 'anthropic', model: 'claude-sonnet-4-5' };
credentialsByProvider.value = { anthropic: 'cred-1' };
personalProject.value = { id: 'p1' };
allCredentials.value = [{ id: 'cred-1', name: 'My Anthropic' }];
});
describe('AskLlmCard', () => {
it('renders the purpose text', async () => {
const wrapper = mountCard({ purpose: 'Pick a main model' });
await flushPromises();
expect(wrapper.text()).toContain('Pick a main model');
});
it('falls back to the default heading when no purpose is supplied', async () => {
const wrapper = mountCard();
await flushPromises();
expect(wrapper.text()).toContain('Choose a model');
});
it('fetches the model catalog on mount', async () => {
mountCard();
await flushPromises();
expect(ensureLoaded).toHaveBeenCalledWith('p1');
});
it('emits a complete resume payload when the model selector emits a change', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="model-selector"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual({
provider: 'anthropic',
model: 'claude-sonnet-4-5',
credentialId: 'cred-1',
credentialName: 'My Anthropic',
});
});
it('does NOT emit when disabled — guards against accidental commits in resolved state', async () => {
const wrapper = mountCard({ disabled: true });
await flushPromises();
const selector = wrapper.find('[data-testid="model-selector"]');
if (selector.exists()) await selector.trigger('click');
expect(wrapper.emitted('submit')).toBeFalsy();
});
it('renders the resolved provider/model and credential name when given resolvedValue', async () => {
const wrapper = mountCard({
disabled: true,
resolvedValue: {
provider: 'anthropic',
model: 'claude-sonnet-4-5',
credentialId: 'cred-1',
credentialName: 'My Anthropic',
},
});
await flushPromises();
const text = wrapper.text();
expect(text).toContain('anthropic/claude-sonnet-4-5');
expect(text).toContain('My Anthropic');
});
it('strips the "models/" prefix from Google model ids before emitting', async () => {
selectorState.selection = { provider: 'google', model: 'models/gemini-2.5-pro' };
credentialsByProvider.value = { google: 'cred-g' };
allCredentials.value = [{ id: 'cred-g', name: 'My Google' }];
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="model-selector"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect((emitted[0][0] as { model: string }).model).toBe('gemini-2.5-pro');
});
});
@@ -1,160 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { afterEach, describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import AskQuestionCard from '../components/interactive/AskQuestionCard.vue';
const OPTIONS = [
{ label: 'Option A', value: 'a' },
{ label: 'Option B', value: 'b', description: 'Extra info' },
];
function mountCard(props = {}) {
return mount(AskQuestionCard, {
props: {
question: 'Which option?',
options: OPTIONS,
...props,
},
global: {
stubs: {
N8nButton: {
template:
'<button v-bind="$attrs" :disabled="$attrs.disabled" @click="$emit(\'click\')"><slot/></button>',
},
N8nCheckbox: {
props: ['modelValue', 'disabled'],
template:
'<button type="button" data-testid="n8n-checkbox" :aria-checked="String(modelValue)" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)"></button>',
},
N8nInput: {
props: ['modelValue', 'disabled', 'placeholder'],
template:
'<input :value="modelValue" :disabled="disabled" :placeholder="placeholder" v-bind="$attrs" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
},
N8nInputLabel: {
props: ['label'],
template: '<label><span>{{ label }}</span><slot /></label>',
},
N8nText: { template: '<p><slot/></p>' },
},
},
});
}
afterEach(() => {
vi.useRealTimers();
});
describe('AskQuestionCard', () => {
it('renders the question and all options', () => {
const wrapper = mountCard();
expect(wrapper.text()).toContain('Which option?');
expect(wrapper.text()).toContain('Option A');
expect(wrapper.text()).toContain('Option B');
expect(wrapper.text()).toContain('Extra info');
expect(wrapper.find('[data-testid="ask-question-other-input"]').exists()).toBe(true);
});
it('renders only the freeform field for an open-ended question (no options)', async () => {
const wrapper = mountCard({ options: [] });
expect(wrapper.findAll('button[aria-pressed]')).toHaveLength(0);
const input = wrapper.find('[data-testid="ask-question-other-input"]');
expect(input.exists()).toBe(true);
await input.setValue('Summarize my inbox');
await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ values: ['Summarize my inbox'] });
});
it('emits submit with selected value after clicking a single-choice option', async () => {
vi.useFakeTimers();
const wrapper = mountCard();
const buttons = wrapper.findAll('button[aria-pressed]');
await buttons[0].trigger('click'); // select Option A
expect(wrapper.find('[data-testid="ask-question-submit"]').exists()).toBe(false);
expect(wrapper.emitted('submit')).toBeFalsy();
vi.advanceTimersByTime(250);
expect(wrapper.emitted('submit')).toBeTruthy();
expect((wrapper.emitted('submit') as unknown[][])[0][0]).toEqual({ values: ['a'] });
});
it('only submits the latest single-choice option when clicks happen quickly', async () => {
vi.useFakeTimers();
const wrapper = mountCard();
const buttons = wrapper.findAll('button[aria-pressed]');
await buttons[0].trigger('click');
vi.advanceTimersByTime(100);
await buttons[1].trigger('click');
vi.advanceTimersByTime(249);
expect(wrapper.emitted('submit')).toBeFalsy();
vi.advanceTimersByTime(1);
expect(wrapper.emitted('submit')).toHaveLength(1);
expect((wrapper.emitted('submit') as unknown[][])[0][0]).toEqual({ values: ['b'] });
});
it('does not emit if nothing is selected', async () => {
const wrapper = mountCard({ allowMultiple: true });
await wrapper.find('[data-testid="ask-question-submit"]').trigger('click');
expect(wrapper.emitted('submit')).toBeFalsy();
});
it('does not emit when disabled', async () => {
const wrapper = mountCard({ disabled: true });
const buttons = wrapper.findAll('button[aria-pressed]');
await buttons[0].trigger('click');
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Different option');
await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click');
expect(wrapper.emitted('submit')).toBeFalsy();
});
it('submits typed Other text in single-choice mode', async () => {
const wrapper = mountCard();
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Microsoft Teams');
await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ values: ['Use Microsoft Teams'] });
});
it('allows selecting multiple values when allowMultiple=true', async () => {
const wrapper = mountCard({ allowMultiple: true });
const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]');
await checkboxes[0].trigger('click');
await checkboxes[1].trigger('click');
expect(checkboxes[0].attributes('aria-checked')).toBe('true');
expect(checkboxes[1].attributes('aria-checked')).toBe('true');
const allBtns = wrapper.findAll('button');
const submitBtn = allBtns[allBtns.length - 1];
await submitBtn.trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ values: ['a', 'b'] });
});
it('submits selected multiple values plus typed Other text', async () => {
const wrapper = mountCard({ allowMultiple: true });
const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]');
await checkboxes[0].trigger('click');
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Discord too');
await wrapper.find('[data-testid="ask-question-submit"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ values: ['a', 'Use Discord too'] });
});
it('allows typed Other text as the only multiple-choice value', async () => {
const wrapper = mountCard({ allowMultiple: true });
await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Linear');
await wrapper.find('[data-testid="ask-question-submit"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ values: ['Use Linear'] });
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import AskQuestionsCard from '../components/interactive/AskQuestionsCard.vue';
import type { InteractionQuestion } from '@n8n/api-types';
const singleQuestion: InteractionQuestion = {
id: 'q1',
question: 'Where should the agent post?',
type: 'single',
options: ['Slack', 'Discord'],
};
const textQuestion: InteractionQuestion = {
id: 'q-text',
question: 'Anything else?',
type: 'text',
};
const renderComponent = createComponentRenderer(AskQuestionsCard);
/**
* The wizard child (`InstanceAiQuestions.vue`) uses the instanceAi subtree's
* `data-test-id` (hyphenated) convention, while this card and the rest of
* `features/agents` use `data-testid`. Query via raw attribute selector
* instead of `getByTestId`/`queryByTestId` so the assertions don't depend on
* whichever attribute name the global test config happens to default to.
*/
function findByRawTestId(container: Element, id: string) {
return container.querySelector<HTMLElement>(`[data-test-id="${id}"], [data-testid="${id}"]`);
}
describe('AskQuestionsCard', () => {
it('renders the wizard (InstanceAiQuestions) for the live card and reports answers via submit', async () => {
const { emitted, container } = renderComponent({
props: { questions: [textQuestion] },
});
expect(findByRawTestId(container, 'ask-questions-card')).toBeTruthy();
const submitButton = findByRawTestId(container, 'instance-ai-questions-next');
expect(submitButton).toBeTruthy();
// The final question submits immediately even with an empty answer
// (matches InstanceAiQuestions.vue's own "skip if empty" behaviour).
await fireEvent.click(submitButton!);
expect(emitted().submit).toEqual([
[
{
approved: true,
answers: [{ questionId: 'q-text', selectedOptions: [], skipped: true }],
},
],
]);
});
it('answers a single-select question by clicking an option', async () => {
const { emitted, getByText } = renderComponent({
props: { questions: [singleQuestion] },
});
await fireEvent.click(getByText('Slack'));
// InstanceAiQuestions.vue delays single-choice auto-advance by 250ms so
// the selection is visible before submitting.
await waitFor(() => expect(emitted().submit).toBeTruthy());
expect(emitted().submit).toEqual([
[{ approved: true, answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }] }],
]);
});
it('replaces the wizard with the resolved answer for each question when disabled', () => {
const { container, getByText } = renderComponent({
props: {
questions: [singleQuestion],
disabled: true,
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }],
},
},
});
expect(findByRawTestId(container, 'instance-ai-questions-next')).toBeNull();
expect(getByText(/Slack/)).toBeTruthy();
});
it('renders the skipped label when the resolvedValue reports no answers', () => {
const { getByText } = renderComponent({
props: {
questions: [singleQuestion],
disabled: true,
resolvedValue: { answered: false },
},
});
expect(getByText('Skipped')).toBeTruthy();
});
});
@@ -0,0 +1,102 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
/**
* `ConfigureChannelCard` is a thin transport adapter around the shared
* `ChannelSetupCard` (body + composable wiring, tested on its own in
* `features/ai/shared/components/ChannelSetupCard.test.ts`). Here we only
* prove the adapter's own job: forwarding props down, mapping the shared
* `resolve` event onto the `submit` emit, and rendering the resolved-state
* summary once disabled.
*/
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (k: string) => k }),
}));
vi.mock('@/features/ai/shared/components/ChannelSetupCard.vue', () => ({
default: {
props: ['integrationType', 'agentId', 'projectId', 'disabled'],
emits: ['resolve'],
// No hardcoded `data-testid` on the root: the adapter passes its own
// (`configure-channel-card`) as a fallthrough attribute, which would
// just overwrite one set here anyway.
template:
'<div :data-integration-type="integrationType" :data-agent-id="agentId" :data-project-id="projectId" :data-disabled="disabled">' +
'<button data-testid="mock-resolve-approved" @click="$emit(\'resolve\', { approved: true })" />' +
'<button data-testid="mock-resolve-skipped" @click="$emit(\'resolve\', { approved: false })" />' +
'</div>',
},
}));
import ConfigureChannelCard from '../components/interactive/ConfigureChannelCard.vue';
const defaultProps = {
integrationType: 'slack',
agentId: 'agent-1',
projectId: 'project-1',
};
function mountCard(props: Record<string, unknown> = {}) {
return mount(ConfigureChannelCard, {
props: { ...defaultProps, ...props },
global: {
stubs: {
N8nIcon: { template: '<i />', props: ['icon', 'size', 'color'] },
N8nText: { template: '<span><slot/></span>', props: ['size', 'bold', 'color', 'tag'] },
},
},
});
}
describe('ConfigureChannelCard', () => {
it('renders the shared channel-setup card with the requested integration wired through', () => {
const wrapper = mountCard();
const stub = wrapper.find('[data-testid="configure-channel-card"]');
expect(stub.exists()).toBe(true);
expect(stub.attributes('data-integration-type')).toBe('slack');
expect(stub.attributes('data-agent-id')).toBe('agent-1');
expect(stub.attributes('data-project-id')).toBe('project-1');
});
it('emits { approved: true } when the shared card resolves connected', async () => {
const wrapper = mountCard();
await wrapper.find('[data-testid="mock-resolve-approved"]').trigger('click');
expect(wrapper.emitted('submit')).toEqual([[{ approved: true }]]);
});
it('emits { approved: false } when the shared card resolves skipped', async () => {
const wrapper = mountCard();
await wrapper.find('[data-testid="mock-resolve-skipped"]').trigger('click');
expect(wrapper.emitted('submit')).toEqual([[{ approved: false }]]);
});
it('does not emit submit twice for a duplicate resolve', async () => {
const wrapper = mountCard();
const button = wrapper.find('[data-testid="mock-resolve-approved"]');
await button.trigger('click');
await button.trigger('click');
expect(wrapper.emitted('submit')).toHaveLength(1);
});
it('renders a connected resolved summary when disabled and resolved as connected', () => {
const wrapper = mountCard({ disabled: true, resolvedValue: { connected: true } });
expect(wrapper.find('[data-testid="configure-channel-card"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="mock-resolve-approved"]').exists()).toBe(false);
expect(wrapper.text()).toContain('agents.channels.modal.connected');
});
it('renders a skipped resolved summary when disabled and resolved as not connected', () => {
const wrapper = mountCard({ disabled: true, resolvedValue: { approved: false } });
expect(wrapper.text()).toContain('agents.chat.configureChannel.skipped');
});
});
@@ -1,11 +1,46 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { mount } from '@vue/test-utils';
import { APPROVAL_TOOL_NAME } from '@n8n/api-types';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
} from '@n8n/api-types';
import { describe, expect, it, vi } from 'vitest';
import InteractiveCard from '../components/interactive/InteractiveCard.vue';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
/**
* The real cards (`AskQuestionsCard`/`AskCredentialCard`/`ConfigureChannelCard`)
* pull in stores and composables that need their own dedicated setup — see
* `AskQuestionsCard.test.ts` / `AskCredentialCard.test.ts` /
* `ConfigureChannelCard.test.ts` for their behavior. Here we only care that
* `InteractiveCard`'s payload-shape dispatch routes to the right one with
* the right props, so stub them out and assert on the props they receive.
*/
vi.mock('../components/interactive/AskQuestionsCard.vue', () => ({
default: {
props: ['questions', 'introMessage', 'disabled', 'resolvedValue'],
template:
'<div data-testid="ask-questions-card-stub" :data-questions="JSON.stringify(questions)" />',
},
}));
vi.mock('../components/interactive/AskCredentialCard.vue', () => ({
default: {
props: ['credentialRequests', 'message', 'projectId', 'disabled', 'resolvedValue'],
template:
'<div data-testid="ask-credential-card-stub" :data-message="message" :data-project-id="projectId" />',
},
}));
vi.mock('../components/interactive/ConfigureChannelCard.vue', () => ({
default: {
props: ['integrationType', 'agentId', 'projectId', 'disabled', 'resolvedValue'],
template:
'<div data-testid="configure-channel-card-stub" :data-integration-type="integrationType" :data-agent-id="agentId" :data-project-id="projectId" />',
},
}));
vi.mock('@n8n/i18n', () => {
const i18n = {
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => {
@@ -109,4 +144,108 @@ describe('InteractiveCard', () => {
expect(wrapper.find('[data-testid="agent-approval-approve"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-approval-reject"]').exists()).toBe(false);
});
it('dispatches to AskQuestionsCard by the `inputType: questions` payload field, not toolName', () => {
const wrapper = mountCard({
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-q',
runId: 'run-q',
input: {
requestId: 'req-1',
message: 'The agent builder has questions',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['a'] }],
},
});
const stub = wrapper.find('[data-testid="ask-questions-card-stub"]');
expect(stub.exists()).toBe(true);
expect(JSON.parse(stub.attributes('data-questions') ?? '[]')).toEqual([
{ id: 'q1', question: 'Pick one', type: 'single', options: ['a'] },
]);
});
it('dispatches to AskCredentialCard by the `credentialRequests` payload field, for both ask_credential and ask_embedding_credential', () => {
const input = {
requestId: 'req-2',
message: 'Slack credential',
severity: 'info' as const,
credentialRequests: [
{ credentialType: 'slackApi', reason: 'Slack credential', existingCredentials: [] },
],
credentialFlow: { stage: 'generic' as const },
};
const wrapper = mountCard({
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'tc-c',
runId: 'run-c',
input,
});
const stub = wrapper.find('[data-testid="ask-credential-card-stub"]');
expect(stub.exists()).toBe(true);
expect(stub.attributes('data-message')).toBe('Slack credential');
});
it('dispatches to ConfigureChannelCard by the `channelConfig` payload field', () => {
const wrapper = mount(InteractiveCard, {
props: {
payload: {
toolName: CONFIGURE_CHANNEL_TOOL_NAME,
toolCallId: 'tc-ch',
runId: 'run-ch',
input: {
requestId: 'req-3',
message: 'Set up the slack channel',
severity: 'info',
channelConfig: { integrationType: 'slack', agentId: 'a1' },
projectId: 'p1',
},
} satisfies InteractivePayload,
projectId: 'p1',
agentId: 'a1',
},
global: {
stubs: {
N8nCard: { template: '<section><slot /></section>' },
N8nText: { template: '<span><slot /></span>', props: ['tag', 'bold', 'size', 'color'] },
N8nIcon: { template: '<i />', props: ['icon', 'size', 'color'] },
N8nButton: { template: '<button><slot /></button>', props: ['disabled'] },
},
},
});
const stub = wrapper.find('[data-testid="configure-channel-card-stub"]');
expect(stub.exists()).toBe(true);
expect(stub.attributes('data-integration-type')).toBe('slack');
expect(stub.attributes('data-agent-id')).toBe('a1');
expect(stub.attributes('data-project-id')).toBe('p1');
});
it('renders nothing, without crashing, for a payload whose toolName does not match the field it carries', () => {
// Malformed/corrupted payload: `channelConfig` is present (which the
// presence-based `matches()` checks for) but `toolName` is neither
// `configure_channel` nor any other known tool. Before the
// toolName+presence hardening, `'channelConfig' in payload.input` alone
// used to match the channel renderer and hand it `{}` from `getProps`
// (a strict toolName narrow), which would crash a real card expecting
// `integrationType` etc.
const malformedPayload = {
toolName: 'unknown_tool',
toolCallId: 'tc-malformed',
runId: 'run-malformed',
input: {
channelConfig: { integrationType: 'slack', agentId: 'a1' },
},
} as unknown as InteractivePayload;
expect(() => mountCard(malformedPayload)).not.toThrow();
const wrapper = mountCard(malformedPayload);
expect(wrapper.find('[data-testid="configure-channel-card-stub"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="ask-questions-card-stub"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="ask-credential-card-stub"]').exists()).toBe(false);
});
});
@@ -1,8 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
APPROVAL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AgentPersistedMessageContentPart,
@@ -17,46 +16,29 @@ import {
import { buildDisplayGroups, isGroupable } from '@/features/ai/shared/agentsChat/displayGroups';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
/** A full `credentialSuspendPayloadSchema`-shaped input, for fixtures that construct `InteractivePayload` literals directly (bypassing `rebuildInteractiveFromHistory`'s raw-args fallback). */
function credentialSuspendInput(credentialType: string, reason: string) {
return {
requestId: 'req-1',
message: reason,
severity: 'info' as const,
credentialRequests: [{ credentialType, reason, existingCredentials: [] }],
credentialFlow: { stage: 'generic' as const },
};
}
/** A full `questionsSuspendPayloadSchema`-shaped input, for fixtures that construct `InteractivePayload` literals directly. */
function questionsSuspendInput(question: string) {
return {
requestId: 'req-q1',
message: question,
severity: 'info' as const,
inputType: 'questions' as const,
questions: [{ id: 'q1', question, type: 'text' as const }],
};
}
describe('rebuildInteractiveFromHistory', () => {
it('rebuilds an OPEN ask_llm card when output is missing', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_LLM_TOOL_NAME,
toolCallId: 'call-1',
input: { purpose: 'pick a model' },
state: 'suspended',
});
expect(result).toBeTruthy();
expect(result?.toolName).toBe(ASK_LLM_TOOL_NAME);
expect(result?.resolvedAt).toBeUndefined();
expect(result?.resolvedValue).toBeUndefined();
// runId is the sidecar's responsibility — raw history doesn't carry it.
expect(result?.runId).toBeUndefined();
});
it('rebuilds a RESOLVED ask_llm card when output is present', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_LLM_TOOL_NAME,
toolCallId: 'call-1',
input: { purpose: 'pick a model' },
output: {
provider: 'anthropic',
model: 'claude-sonnet-4',
credentialId: 'cred-1',
credentialName: 'My Anthropic',
},
state: 'done',
});
expect(result?.resolvedAt).toBeGreaterThan(0);
expect(result?.resolvedValue).toEqual({
provider: 'anthropic',
model: 'claude-sonnet-4',
credentialId: 'cred-1',
credentialName: 'My Anthropic',
});
});
it('rebuilds an ask_credential card with skipped resolved value', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_CREDENTIAL_TOOL_NAME,
@@ -138,9 +120,11 @@ describe('convertDbMessages — interactive turn synthesis', () => {
content: [
{
type: 'tool-call',
toolName: ASK_LLM_TOOL_NAME,
toolCallId: 'call-llm-1',
input: { purpose: 'main' },
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'call-q-1',
input: {
questions: [{ id: 'q1', question: 'Which model?', type: 'text' }],
},
state: 'pending',
},
],
@@ -152,7 +136,7 @@ describe('convertDbMessages — interactive turn synthesis', () => {
const assistant = chat[1];
expect(assistant.role).toBe('assistant');
expect(assistant.status).toBe('awaitingUser');
expect(assistant.interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(assistant.interactive?.resolvedAt).toBeUndefined();
expect(assistant.toolCalls?.[0].state).toBe('suspended');
});
@@ -165,17 +149,20 @@ describe('convertDbMessages — interactive turn synthesis', () => {
content: [
{
type: 'tool-call',
toolName: ASK_QUESTION_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'q-1',
input: {
question: 'Where to post?',
options: [
{ label: 'Slack', value: 'slack' },
{ label: 'Discord', value: 'discord' },
questions: [
{
id: 'q1',
question: 'Where to post?',
type: 'single',
options: ['Slack', 'Discord'],
},
],
},
state: 'resolved',
output: { values: ['slack'] },
output: { answered: true, answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }] },
},
],
},
@@ -185,10 +172,16 @@ describe('convertDbMessages — interactive turn synthesis', () => {
expect(chat).toHaveLength(1);
const assistant = chat[0];
expect(assistant.toolCalls?.[0].state).toBe('done');
expect(assistant.toolCalls?.[0].output).toEqual({ values: ['slack'] });
expect(assistant.interactive?.toolName).toBe(ASK_QUESTION_TOOL_NAME);
expect(assistant.toolCalls?.[0].output).toEqual({
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }],
});
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(assistant.interactive?.resolvedAt).toBeDefined();
expect(assistant.interactive?.resolvedValue).toEqual({ values: ['slack'] });
expect(assistant.interactive?.resolvedValue).toEqual({
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }],
});
});
it('preserves multiple resolved n8n chat cards from one persisted assistant message', () => {
@@ -450,11 +443,11 @@ describe('isGroupable', () => {
id: 'm1',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c1', state: 'suspended' }],
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'suspended' }],
interactive: {
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'c1',
input: {},
input: questionsSuspendInput('Which model?'),
},
status: 'awaitingUser',
});
@@ -476,22 +469,20 @@ describe('isGroupable', () => {
describe('buildDisplayGroups — interactive payloads', () => {
it('collects interactive payloads from each grouped message into the toolRun group', () => {
const groups = buildDisplayGroups([
// First grouped turn: a resolved ask_llm card
// First grouped turn: a resolved ask_questions card
{
id: 'm1',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c1', state: 'done' }],
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'done' }],
interactive: {
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'c1',
input: {},
input: questionsSuspendInput('Which model?'),
resolvedAt: 1,
resolvedValue: {
provider: 'a',
model: 'b',
credentialId: 'x',
credentialName: 'y',
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
},
},
status: 'success',
@@ -513,7 +504,7 @@ describe('buildDisplayGroups — interactive payloads', () => {
interactive: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'c3',
input: { purpose: 'Slack', credentialType: 'slackApi' },
input: credentialSuspendInput('slackApi', 'Slack'),
},
status: 'awaitingUser',
},
@@ -525,7 +516,7 @@ describe('buildDisplayGroups — interactive payloads', () => {
if (grouped.kind !== 'toolRun') return;
expect(grouped.toolCalls).toHaveLength(3);
expect(grouped.interactives).toHaveLength(2);
expect(grouped.interactives[0].toolName).toBe(ASK_LLM_TOOL_NAME);
expect(grouped.interactives[0].toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(grouped.interactives[0].resolvedAt).toBeDefined();
expect(grouped.interactives[1].toolName).toBe(ASK_CREDENTIAL_TOOL_NAME);
expect(grouped.interactives[1].resolvedAt).toBeUndefined();
@@ -637,7 +628,7 @@ describe('applyOpenSuspensions', () => {
interactive: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'c-open',
input: { purpose: 'Slack', credentialType: 'slackApi' },
input: credentialSuspendInput('slackApi', 'Slack'),
},
status: 'awaitingUser',
},
@@ -645,17 +636,15 @@ describe('applyOpenSuspensions', () => {
id: 'm2',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_LLM_TOOL_NAME, toolCallId: 'c-resolved', state: 'done' }],
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c-resolved', state: 'done' }],
interactive: {
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'c-resolved',
input: { purpose: 'main' },
input: questionsSuspendInput('Which model?'),
resolvedAt: 1,
resolvedValue: {
provider: 'a',
model: 'b',
credentialId: 'x',
credentialName: 'y',
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
},
},
status: 'success',
@@ -676,9 +665,9 @@ describe('applyOpenSuspensions', () => {
role: 'assistant',
content: '',
interactive: {
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'c1',
input: { purpose: 'main' },
input: questionsSuspendInput('Which model?'),
},
},
];
@@ -694,9 +683,9 @@ describe('applyOpenSuspensions', () => {
role: 'assistant',
content: '',
interactive: {
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'c1',
input: { purpose: 'main' },
input: questionsSuspendInput('Which model?'),
},
},
];
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
} from '@n8n/api-types';
import {
@@ -18,34 +18,34 @@ describe('summariseInteractiveOutput', () => {
});
it('returns undefined when output is missing', () => {
expect(summariseInteractiveOutput(ASK_QUESTION_TOOL_NAME, undefined)).toBeUndefined();
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, undefined)).toBeUndefined();
});
it.each([null, 'oops', 42, true, ['x']])(
'returns undefined for non-object output (%p)',
(value) => {
expect(summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, value)).toBeUndefined();
expect(summariseInteractiveOutput(ASK_LLM_TOOL_NAME, value)).toBeUndefined();
expect(summariseInteractiveOutput(ASK_QUESTION_TOOL_NAME, value)).toBeUndefined();
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, value)).toBeUndefined();
},
);
it('joins ask_question option labels when input + output present', () => {
const input = {
question: 'Where to post?',
options: [
{ label: 'Slack', value: 'slack' },
{ label: 'Discord', value: 'discord' },
it('joins ask_questions answer labels when output is present', () => {
const output = {
answered: true,
answers: [
{ questionId: 'q1', selectedOptions: ['Slack', 'Discord'] },
{ questionId: 'q2', selectedOptions: [], skipped: true },
],
};
const output = { values: ['slack', 'discord'] };
expect(summariseInteractiveOutput(ASK_QUESTION_TOOL_NAME, output, input)).toBe(
'Slack, Discord',
);
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, output)).toBe('Slack, Discord');
});
it('falls back to raw values when ask_question input is missing', () => {
expect(summariseInteractiveOutput(ASK_QUESTION_TOOL_NAME, { values: ['slack'] })).toBe('slack');
it('returns undefined when every ask_questions answer was skipped', () => {
const output = {
answered: false,
answers: [{ questionId: 'q1', selectedOptions: [], skipped: true }],
};
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, output)).toBeUndefined();
});
it('renders ask_credential credential name', () => {
@@ -61,15 +61,20 @@ describe('summariseInteractiveOutput', () => {
expect(summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, { skipped: true })).toBe('Skipped');
});
it('renders ask_llm provider/model + credential', () => {
it('renders the optimistic resume shape as Selected before the real output arrives', () => {
expect(
summariseInteractiveOutput(ASK_LLM_TOOL_NAME, {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
credentialId: 'c1',
credentialName: 'My Anthropic',
}),
).toBe('anthropic/claude-sonnet-4-6 · My Anthropic');
summariseToolCall(ASK_CREDENTIAL_TOOL_NAME, { credentials: { slackApi: 'cred-1' } }),
).toBe('Selected');
expect(summariseToolCall(ASK_CREDENTIAL_TOOL_NAME, { credentials: {} })).toBeUndefined();
});
it('renders configure_channel connected/skipped', () => {
expect(summariseInteractiveOutput(CONFIGURE_CHANNEL_TOOL_NAME, { connected: true })).toBe(
'Connected',
);
expect(summariseInteractiveOutput(CONFIGURE_CHANNEL_TOOL_NAME, { connected: false })).toBe(
'Skipped',
);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { ASK_CREDENTIAL_TOOL_NAME, ASK_QUESTION_TOOL_NAME } from '@n8n/api-types';
import { ASK_CREDENTIAL_TOOL_NAME, ASK_QUESTIONS_TOOL_NAME } from '@n8n/api-types';
import { TOOL_CALL_STATE } from '../constants';
import {
DELEGATED_CHILD_SUSPEND_UNSUPPORTED_MESSAGE,
@@ -72,7 +72,7 @@ describe('tool-call-details', () => {
it('does not expose resolved interactive tool resume payloads', () => {
expect(
getToolCallDetails({
tool: ASK_QUESTION_TOOL_NAME,
tool: ASK_QUESTIONS_TOOL_NAME,
output: { values: ['slack'] },
state: TOOL_CALL_STATE.DONE,
}),
@@ -9,7 +9,7 @@ import {
describe('formatToolNameForDisplay', () => {
it('formats snake_case builder tool names as readable labels', () => {
expect(formatToolNameForDisplay('create_skill')).toBe('Create skill');
expect(formatToolNameForDisplay('ask_llm')).toBe('Ask LLM');
expect(formatToolNameForDisplay('resolve_llm')).toBe('Resolve LLM');
expect(formatToolNameForDisplay('build_custom_tool')).toBe('Build custom tool');
expect(formatToolNameForDisplay('update_memory')).toBe('Update memory');
});
@@ -4,7 +4,7 @@ import { ref, nextTick } from 'vue';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AgentSseEvent,
} from '@n8n/api-types';
@@ -78,15 +78,15 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
vi.restoreAllMocks();
});
it('renders an interactive ask_llm card and stamps the runId from the suspended event', async () => {
it('renders an interactive ask_questions card and stamps the runId from the suspended event', async () => {
const events: AgentSseEvent[] = [
{
type: 'tool-call-suspended',
payload: {
toolCallId: 'tc-1',
runId: 'run-42',
toolName: ASK_LLM_TOOL_NAME,
input: { purpose: 'main model' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
},
{ type: 'done' },
@@ -103,7 +103,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
expect(assistant.status).toBe('awaitingUser');
expect(assistant.toolCalls).toHaveLength(1);
expect(assistant.toolCalls?.[0].state).toBe('suspended');
expect(assistant.interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(assistant.interactive?.runId).toBe('run-42');
expect(assistant.interactive?.resolvedValue).toBeUndefined();
expect(assistant.interactive?.resolvedAt).toBeUndefined();
@@ -117,7 +117,15 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
toolCallId: 'tc-1',
runId: 'run-42',
toolName: ASK_CREDENTIAL_TOOL_NAME,
input: { purpose: 'Slack', credentialType: 'slackApi' },
input: {
requestId: 'req-1',
message: 'Slack',
severity: 'info',
credentialRequests: [
{ credentialType: 'slackApi', reason: 'Slack', existingCredentials: [] },
],
credentialFlow: { stage: 'generic' },
},
},
},
{
@@ -151,16 +159,16 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: ASK_LLM_TOOL_NAME,
input: { purpose: 'main' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
{
type: 'tool-call-suspended',
payload: {
toolCallId: 'tc-1',
runId: 'run-7',
toolName: ASK_LLM_TOOL_NAME,
input: { purpose: 'main' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
},
{ type: 'done' },
@@ -347,18 +355,18 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
// in place, not push a duplicate into a freshly-minted ChatMessage.
const events: AgentSseEvent[] = [
{ type: 'start-step' },
{ type: 'tool-input-start', toolCallId: 'tc-1', toolName: ASK_LLM_TOOL_NAME },
{ type: 'tool-input-start', toolCallId: 'tc-1', toolName: ASK_QUESTIONS_TOOL_NAME },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: ASK_LLM_TOOL_NAME,
input: { purpose: 'main' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
{ type: 'finish-step' },
{
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
startTime: 1_000,
},
{
@@ -366,8 +374,8 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
payload: {
toolCallId: 'tc-1',
runId: 'run-9',
toolName: ASK_LLM_TOOL_NAME,
input: { purpose: 'main' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
},
{ type: 'done' },
@@ -925,27 +933,34 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
).toBeUndefined();
});
it('builder tool (ask_question) still sets tc.input from suspend payload and builds card', async () => {
it('builder tool (ask_questions) still sets tc.input from suspend payload and builds card', async () => {
const askInput = {
question: 'What is your preferred language?',
options: [
{ label: 'TypeScript', value: 'ts' },
{ label: 'Python', value: 'py' },
requestId: 'req-1',
message: 'The agent builder has questions',
severity: 'info',
inputType: 'questions',
questions: [
{
id: 'q1',
question: 'What is your preferred language?',
type: 'single',
options: ['TypeScript', 'Python'],
},
],
};
const events: AgentSseEvent[] = [
{
type: 'tool-call',
toolCallId: 'tc-ask',
toolName: 'ask_question',
input: { question: 'placeholder' },
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'placeholder', type: 'single', options: ['a'] }] },
},
{
type: 'tool-call-suspended',
payload: {
toolCallId: 'tc-ask',
runId: 'run-ask',
toolName: 'ask_question',
toolName: ASK_QUESTIONS_TOOL_NAME,
input: askInput,
},
},
@@ -962,7 +977,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
expect(tc.input).toEqual(askInput); // overwritten from suspend payload (builder behaviour)
expect(tc.suspendPayload).toBeUndefined();
expect(tc.state).toBe('suspended');
expect(msg.interactive?.toolName).toBe('ask_question');
expect(msg.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(msg.interactive?.runId).toBe('run-ask');
expect(msg.status).toBe('awaitingUser');
});
@@ -110,6 +110,7 @@ const i18n = useI18n();
:is-published="Boolean(agent?.activeVersionId)"
:task-refs="localConfig?.tasks ?? []"
:reload-key="tasksReloadKey"
:simple-channel-setup="artifactMode"
@open-tool="emit('open-tool', $event)"
@open-skill="emit('open-skill', $event)"
@add-tool="emit('add-tool')"
@@ -47,11 +47,19 @@ const props = withDefaults(
* also suppresses the inline `AgentChannelModal`.
*/
sections?: AgentCapabilitySection[];
/**
* Restricts the channel modal to the simple, guided per-channel setup used by
* the AI-assistant channel-setup HITL card (forces a new credential, skips
* the advanced list/edit UI). Set by the artifact-mode agent preview embedded
* in the AI assistant; the standalone Agent Builder leaves this off.
*/
simpleChannelSetup?: boolean;
}>(),
{
disabled: false,
taskRefs: () => [],
sections: () => ['channels', 'tools', 'skills', 'subAgents', 'tasks'],
simpleChannelSetup: false,
},
);
@@ -702,6 +710,7 @@ function handleChannelDisconnected(channelType: string) {
:project-id="projectId"
:connected-channels="connectedTriggers"
:is-published="isPublished"
:simple-setup="simpleChannelSetup"
@channel-connected="handleChannelConnected"
@channel-disconnected="handleChannelDisconnected"
@agent-changed="emit('agent-changed')"
@@ -41,10 +41,18 @@ interface Props {
* picker). Used by the AIA channel-setup HITL so a new agent gets its own credential.
*/
forceNewCredential?: boolean;
/**
* Restrict the modal to the simple, guided per-channel setup — used when the
* modal is opened from the AI-assistant-embedded agent preview (artifact mode).
* Forces a new credential and skips the advanced list/edit UI: any request to
* edit a connected channel is redirected to the simple setup view instead.
*/
simpleSetup?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
forceNewCredential: false,
simpleSetup: false,
});
const emit = defineEmits<{
@@ -69,12 +77,21 @@ const {
disconnect,
} = useAgentIntegrationStatus(props.projectId, props.agentId);
const currentView = ref<ChannelView>(props.view);
// In simple-setup mode there is no advanced edit UI, so any request to edit a
// connected channel is redirected to its (simple) setup view instead.
function normalizeView(view: ChannelView): ChannelView {
if (props.simpleSetup && view.endsWith('_edit')) {
return view.replace('_edit', '_setup') as ChannelView;
}
return view;
}
const currentView = ref<ChannelView>(normalizeView(props.view));
watch(
() => props.view,
(newView) => {
currentView.value = newView;
currentView.value = normalizeView(newView);
},
);
@@ -121,6 +138,10 @@ const showFooterActions = computed(
isEditMode.value && selectedChannelType.value !== null && selectedChannelType.value !== 'slack',
);
// Simple-setup mode always creates a fresh credential — mirrors the AIA
// channel-setup HITL card so the artifact preview gets the same guided flow.
const effectiveForceNewCredential = computed(() => props.forceNewCredential || props.simpleSetup);
const currentChannelCredentialId = computed(() =>
getChannelCredentialId(selectedChannelType.value),
);
@@ -177,7 +198,7 @@ function goToSetup(channelType: string) {
}
function goToEdit(channelType: string) {
currentView.value = `${channelType}_edit` as ChannelView;
currentView.value = normalizeView(`${channelType}_edit` as ChannelView);
}
function goBackToList() {
@@ -236,7 +257,7 @@ watch(
(isOpen) => {
if (isOpen) {
void loadChannelState();
currentView.value = props.view;
currentView.value = normalizeView(props.view);
}
},
{ immediate: true },
@@ -318,7 +339,8 @@ watch(
:loading="isLoading('slack')"
:error-message="hasError('slack') ? errorMessages.slack : ''"
:error-is-conflict="errorIsConflict.slack"
:force-new-credential="forceNewCredential"
:force-new-credential="effectiveForceNewCredential"
:setup-mode="simpleSetup ? 'simple' : 'advanced'"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@@ -344,7 +366,7 @@ watch(
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="forceNewCredential"
:force-new-credential="effectiveForceNewCredential"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@@ -370,7 +392,7 @@ watch(
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="forceNewCredential"
:force-new-credential="effectiveForceNewCredential"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
@@ -1,28 +1,37 @@
<script setup lang="ts">
import { computed, provide, ref } from 'vue';
/**
* Card for the `ask_credential` / `ask_embedding_credential` builder tools.
* Reuses the same building blocks as `InstanceAiCredentialSetup.vue`
* (`CredentialIcon`, `NodeCredentials`, `useWizardNavigation`) since both
* surfaces suspend with the identical `credentialSuspendPayloadSchema`
* shape — only the resume transport differs: this card posts to
* `POST /build/resume` (via the `submit` emit) instead of instance AI's own
* confirm endpoint, and skips the browser-auto-setup extras that are
* specific to instance AI.
*/
import { computed, provide, ref, watch } from 'vue';
import { N8nButton, N8nCard, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useI18n } from '@n8n/i18n';
import type { CredentialResumeData, InstanceAiCredentialRequest } from '@n8n/api-types';
import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue';
import CredentialIcon from '@/features/credentials/components/CredentialIcon.vue';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { AskCredentialResume, AskEmbeddingCredentialResume } from '@n8n/api-types';
import { useWizardNavigation } from '@/features/ai/shared/composables/useWizardNavigation';
import { getAppNameFromCredType } from '@/app/utils/nodeTypesUtils';
import type { INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import { ChatHubToolContextKey } from '@/app/constants';
const MANAGED_CREDENTIAL_LABEL_KEY = 'agents.chat.askCredential.managed' as BaseTextKey;
import type { CredentialResolvedValue } from '@/features/ai/shared/agentsChat/types';
const props = defineProps<{
purpose: string;
credentialType: string;
nodeType?: string;
credentialSlot?: string;
projectId: string;
agentId: string;
credentialRequests: InstanceAiCredentialRequest[];
message: string;
projectId?: string;
disabled?: boolean;
resolvedValue?: AskCredentialResume | AskEmbeddingCredentialResume;
resolvedValue?: CredentialResolvedValue;
}>();
const emit = defineEmits<{
submit: [resumeData: { credentialId: string; credentialName: string } | { skipped: true }];
submit: [resumeData: CredentialResumeData];
}>();
const i18n = useI18n();
@@ -30,81 +39,174 @@ const credentialsStore = useCredentialsStore();
provide(ChatHubToolContextKey, true);
// ---------------------------------------------------------------------------
// Selection state — driven entirely by NodeCredentials' credentialSelected event
// ---------------------------------------------------------------------------
const totalSteps = computed(() => props.credentialRequests.length);
const { currentStepIndex, isPrevDisabled, isNextDisabled, goToNext, goToPrev, goToStep } =
useWizardNavigation({ totalSteps });
const selectedId = ref<string>('');
const selectedCredential = computed(() =>
selectedId.value ? credentialsStore.getCredentialById(selectedId.value) : null,
const currentRequest = computed(() => props.credentialRequests[currentStepIndex.value]);
const showArrows = computed(() => props.credentialRequests.length > 1);
const submitted = ref(false);
const selections = ref<Record<string, string | null>>({});
for (const req of props.credentialRequests) {
selections.value[req.credentialType] =
req.existingCredentials.length === 1 ? req.existingCredentials[0].id : null;
}
function isStepComplete(credentialType: string): boolean {
return selections.value[credentialType] !== null;
}
const allSelected = computed(() =>
props.credentialRequests.every((r) => isStepComplete(r.credentialType)),
);
/**
* Synthetic node passed to NodeCredentials. We use `noOp` as the carrier node
* type because the component only needs *some* INodeUi to attach the selected
* credential to; the actual credential type is forced via `override-cred-type`.
* This mirrors the pattern used in InstanceAiCredentialSetup.vue.
*/
const nodeForCredentials = computed<INodeUi>(() => {
const cred = selectedCredential.value;
function getDisplayName(credentialType: string): string {
const raw =
credentialsStore.getCredentialTypeByName(credentialType)?.displayName ?? credentialType;
return getAppNameFromCredType(raw);
}
function syntheticNodeUi(req: InstanceAiCredentialRequest): INodeUi {
const selectedId = selections.value[req.credentialType];
const selectedCred = selectedId
? (req.existingCredentials.find((c) => c.id === selectedId) ??
credentialsStore.getCredentialById(selectedId))
: undefined;
return {
id: props.credentialType,
name: props.credentialType,
id: req.credentialType,
name: req.credentialType,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: cred ? { [props.credentialType]: { id: cred.id, name: cred.name } } : {},
credentials: selectedCred
? { [req.credentialType]: { id: selectedCred.id, name: selectedCred.name } }
: {},
} as INodeUi;
});
function onCredentialSelected(info: INodeUpdatePropertiesInformation) {
if (props.disabled) return;
const data = info.properties.credentials?.[props.credentialType];
if (data && typeof data === 'object' && data.id) {
if (data.id === selectedId.value) return;
selectedId.value = data.id;
emit('submit', {
credentialId: data.id,
credentialName: data.name ?? selectedCredential.value?.name ?? '',
});
} else {
selectedId.value = '';
}
}
// ---------------------------------------------------------------------------
// Submit / skip
// ---------------------------------------------------------------------------
//
// Credentials + credential types are pre-fetched by AgentBuilderView when the
// agent loads, so NodeCredentials renders against an already-warm store.
function onCredentialSelected(credentialType: string, info: INodeUpdatePropertiesInformation) {
if (props.disabled) return;
const data = info.properties.credentials?.[credentialType];
selections.value[credentialType] = data && typeof data === 'object' && data.id ? data.id : null;
}
function submitCredentials() {
if (submitted.value || props.disabled) return;
submitted.value = true;
const credentials: Record<string, string> = {};
for (const [type, id] of Object.entries(selections.value)) {
if (id) credentials[type] = id;
}
emit('submit', { credentials });
}
function onSkip() {
if (props.disabled) return;
if (submitted.value || props.disabled) return;
submitted.value = true;
emit('submit', { skipped: true });
}
// Auto-advance to the next incomplete step, then auto-submit once every
// credential is selected — mirrors the assistant's own wizard so a single
// pick (the common case: `credentialRequests` almost always has exactly one
// entry) submits immediately without an extra "Continue" click.
watch(
() => currentRequest.value && isStepComplete(currentRequest.value.credentialType),
(complete, prevComplete) => {
if (!complete || prevComplete) return;
const nextIncomplete = props.credentialRequests.findIndex(
(r, idx) => idx > currentStepIndex.value && !isStepComplete(r.credentialType),
);
if (nextIncomplete >= 0) goToStep(nextIncomplete);
},
);
watch(allSelected, (nowComplete, wasComplete) => {
if (nowComplete && !wasComplete) submitCredentials();
});
// ---------------------------------------------------------------------------
// Resolved (disabled) state
// ---------------------------------------------------------------------------
const isSkipped = computed(() => {
const value = props.resolvedValue;
if (!value) return false;
if ('skipped' in value) return value.skipped === true;
if ('approved' in value) return value.approved === false;
return false;
});
const resolvedLabel = computed(() => {
const value = props.resolvedValue;
if (!value || isSkipped.value) return undefined;
if ('credentialName' in value) return value.credentialName;
if ('credentials' in value) {
const id = value.credentials[currentRequest.value?.credentialType ?? ''];
return id ? credentialsStore.getCredentialById(id)?.name : undefined;
}
return undefined;
});
</script>
<template>
<N8nCard :class="[$style.card, disabled && $style.disabled]" data-testid="ask-credential-card">
<div :class="$style.cardBody">
<N8nText tag="p" bold :class="$style.purpose">{{ purpose }}</N8nText>
<N8nText tag="p" bold :class="$style.purpose">{{ message }}</N8nText>
<div :class="$style.credentialContainer">
<NodeCredentials
:node="nodeForCredentials"
:override-cred-type="credentialType"
:project-id="projectId"
:readonly="disabled"
standalone
hide-issues
skip-auto-select
@credential-selected="onCredentialSelected"
/>
</div>
<template v-if="!disabled && currentRequest">
<header :class="$style.header">
<CredentialIcon :credential-type-name="currentRequest.credentialType" :size="16" />
<N8nText size="small" bold>{{ getDisplayName(currentRequest.credentialType) }}</N8nText>
</header>
<div :class="$style.credentialContainer">
<NodeCredentials
:node="syntheticNodeUi(currentRequest)"
:override-cred-type="currentRequest.credentialType"
:project-id="projectId"
:readonly="disabled"
standalone
hide-issues
skip-auto-select
@credential-selected="
(info) => onCredentialSelected(currentRequest.credentialType, info)
"
/>
</div>
</template>
<div v-if="!disabled" :class="$style.actions">
<div v-if="showArrows" :class="$style.nav">
<N8nButton
variant="ghost"
size="small"
icon-only
:disabled="isPrevDisabled"
data-testid="ask-credential-prev"
aria-label="Previous credential"
@click="goToPrev"
>
<N8nIcon icon="chevron-left" size="xsmall" />
</N8nButton>
<N8nText size="small" color="text-light">
{{ currentStepIndex + 1 }} / {{ credentialRequests.length }}
</N8nText>
<N8nButton
variant="ghost"
size="small"
icon-only
:disabled="isNextDisabled"
data-testid="ask-credential-next"
aria-label="Next credential"
@click="goToNext"
>
<N8nIcon icon="chevron-right" size="xsmall" />
</N8nButton>
</div>
<N8nButton
size="medium"
variant="outline"
@@ -115,25 +217,14 @@ function onSkip() {
</N8nButton>
</div>
<div v-else :class="$style.resolvedRow">
<template v-if="resolvedValue && 'skipped' in resolvedValue">
<N8nText size="small" color="text-light">Skipped</N8nText>
<template v-if="isSkipped">
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.chat.askCredential.skipped') }}
</N8nText>
</template>
<template v-else>
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small">
{{
(resolvedValue &&
'credential' in resolvedValue &&
resolvedValue.credential === 'managed'
? i18n.baseText(MANAGED_CREDENTIAL_LABEL_KEY)
: null) ??
(resolvedValue && 'credentialName' in resolvedValue
? resolvedValue.credentialName
: null) ??
selectedCredential?.name ??
'—'
}}
</N8nText>
<N8nText size="small">{{ resolvedLabel ?? '—' }}</N8nText>
</template>
</div>
</div>
@@ -164,6 +255,12 @@ function onSkip() {
font-size: var(--font-size--sm);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.credentialContainer {
display: flex;
flex-direction: column;
@@ -176,11 +273,18 @@ function onSkip() {
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
gap: var(--spacing--2xs);
padding-top: var(--spacing--2xs);
}
.nav {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.resolvedRow {
display: flex;
align-items: center;
@@ -1,150 +0,0 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { N8nCard, N8nText, N8nIcon } from '@n8n/design-system';
import type { AskLlmResume } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useAgentModelCredentials } from '../../composables/useAgentModelCredentials';
import { useAgentProjectId } from '../../composables/useAgentProjectId';
import AgentModelSelector from '../AgentModelSelector.vue';
import { sanitizeModelId } from '../../utils/model-string';
import { useModelCatalog } from '../../composables/useModelCatalog';
import {
type AgentModelProvider,
type AgentModelSelection,
type AgentModelsByProvider,
} from '../../model-providers';
const props = defineProps<{
purpose?: string;
disabled?: boolean;
resolvedValue?: AskLlmResume;
projectId?: string;
}>();
const emit = defineEmits<{
submit: [
resumeData: {
provider: string;
model: string;
credentialId: string;
credentialName: string;
},
];
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
const credentialsStore = useCredentialsStore();
const { ensureLoaded, getModelsForPicker, isLoading } = useModelCatalog();
const projectId = useAgentProjectId(() => props.projectId);
const { credentialsByProvider, selectCredential } = useAgentModelCredentials(
usersStore.currentUserId ?? 'anonymous',
projectId,
);
watch(
projectId,
(id) => {
if (id) void ensureLoaded(id);
},
{ immediate: true },
);
const filteredAgents = computed<AgentModelsByProvider>(() =>
getModelsForPicker(credentialsByProvider.value),
);
function onModelChange(selection: AgentModelSelection) {
if (props.disabled) return;
const credentialId = credentialsByProvider.value?.[selection.provider] ?? '';
if (!credentialId) return;
const credentialName =
credentialsStore.allCredentials.find((c) => c.id === credentialId)?.name ?? '';
const model = sanitizeModelId(selection.provider, selection.model);
emit('submit', { provider: selection.provider, model, credentialId, credentialName });
}
function onSelectCredential(provider: AgentModelProvider, credentialId: string | null) {
selectCredential(provider, credentialId);
}
</script>
<template>
<N8nCard :class="[$style.card, disabled && $style.disabled]" data-testid="ask-llm-card">
<div :class="$style.cardBody">
<N8nText tag="p" bold :class="$style.purpose">
{{ purpose ?? i18n.baseText('agents.askLlm.chooseModel') }}
</N8nText>
<!-- Resolved state: show what was selected instead of the live picker -->
<div v-if="disabled && resolvedValue" :class="$style.resolved">
<N8nIcon icon="circle-check" size="small" color="success" />
<div :class="$style.resolvedDetails">
<N8nText bold size="small"
>{{ resolvedValue.provider }}/{{ resolvedValue.model }}</N8nText
>
<N8nText size="small" color="text-light">{{ resolvedValue.credentialName }}</N8nText>
</div>
</div>
<!-- Live picker: shown when not yet resolved -->
<AgentModelSelector
v-else
:selected-model="null"
:credentials="credentialsByProvider"
:models-by-provider="filteredAgents"
:is-loading="isLoading"
:project-id="projectId"
:warn-missing-credentials="true"
@change="onModelChange"
@select-credential="onSelectCredential"
/>
</div>
</N8nCard>
</template>
<style lang="scss" module>
.card {
--card--padding: var(--spacing--sm);
gap: var(--spacing--xs);
width: 90%;
max-width: 90%;
}
.disabled {
opacity: 0.75;
pointer-events: none;
}
.cardBody {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.resolved {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
color: var(--color--success);
}
.resolvedDetails {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.purpose {
margin: 0;
font-size: var(--font-size--sm);
}
</style>
@@ -1,324 +0,0 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue';
import {
N8nButton,
N8nCard,
N8nCheckbox,
N8nIcon,
N8nInput,
N8nInputLabel,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AskQuestionResume } from '@n8n/api-types';
interface Option {
label: string;
value: string;
description?: string;
}
const props = defineProps<{
question: string;
options: Option[];
allowMultiple?: boolean;
disabled?: boolean;
resolvedValue?: AskQuestionResume;
}>();
const emit = defineEmits<{
submit: [resumeData: { values: string[] }];
}>();
const SINGLE_CHOICE_SUBMIT_DELAY_MS = 250;
const i18n = useI18n();
const selected = ref<string[]>([]);
const otherText = ref('');
let singleChoiceSubmitTimer: number | undefined;
/** Labels of the persisted selected values, for the resolved state. */
const resolvedLabels = computed(() => {
if (!props.resolvedValue) return [];
return props.resolvedValue.values.map(
(v) => props.options.find((o) => o.value === v)?.label ?? v,
);
});
const trimmedOtherText = computed(() => otherText.value.trim());
const selectedValuesWithOther = computed(() => {
const values = [...selected.value];
if (trimmedOtherText.value) values.push(trimmedOtherText.value);
return values;
});
// With no options the card is a pure open-ended question, so the freeform field
// is the answer itself rather than an "Other" alternative to listed choices.
const isOpenEnded = computed(() => props.options.length === 0);
const freeformLabel = computed(() =>
isOpenEnded.value
? i18n.baseText('agents.chat.askQuestion.answerLabel')
: i18n.baseText('agents.chat.askQuestion.otherLabel'),
);
const freeformPlaceholder = computed(() =>
isOpenEnded.value
? i18n.baseText('agents.chat.askQuestion.answerPlaceholder')
: i18n.baseText('agents.chat.askQuestion.otherPlaceholder'),
);
function selectSingle(value: string) {
if (props.disabled) return;
selected.value = [value];
clearSingleChoiceSubmitTimer();
singleChoiceSubmitTimer = window.setTimeout(() => {
singleChoiceSubmitTimer = undefined;
if (props.disabled || selected.value[0] !== value) return;
emit('submit', { values: [value] });
}, SINGLE_CHOICE_SUBMIT_DELAY_MS);
}
function clearSingleChoiceSubmitTimer() {
if (singleChoiceSubmitTimer === undefined) return;
window.clearTimeout(singleChoiceSubmitTimer);
singleChoiceSubmitTimer = undefined;
}
function toggleMultiple(value: string, checked: boolean) {
if (props.disabled) return;
const idx = selected.value.indexOf(value);
if (checked && idx < 0) {
selected.value.push(value);
} else if (!checked && idx >= 0) {
selected.value.splice(idx, 1);
}
}
function onSubmit() {
const values = selectedValuesWithOther.value;
if (values.length === 0 || props.disabled) return;
emit('submit', { values });
}
function submitOther() {
if (!trimmedOtherText.value || props.disabled) return;
clearSingleChoiceSubmitTimer();
emit('submit', { values: [trimmedOtherText.value] });
}
function onOtherKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
event.preventDefault();
if (props.allowMultiple) {
onSubmit();
return;
}
submitOther();
}
onBeforeUnmount(clearSingleChoiceSubmitTimer);
</script>
<template>
<N8nCard :class="[$style.card, disabled && $style.disabled]" data-testid="ask-question-card">
<div :class="$style.cardBody">
<N8nText tag="p" bold :class="$style.question">{{ question }}</N8nText>
<!-- Resolved state: show selected labels instead of interactive buttons -->
<div v-if="disabled && resolvedValue" :class="$style.resolved">
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small">{{ resolvedLabels.join(', ') }}</N8nText>
</div>
<!-- Live state: interactive option buttons -->
<template v-else>
<div :class="$style.options">
<template v-if="!allowMultiple">
<button
v-for="(opt, index) in options"
:key="opt.value"
:class="[
$style.option,
selected.includes(opt.value) && $style.activeSelected,
disabled && $style.optionDisabled,
]"
:disabled="disabled"
:aria-pressed="selected.includes(opt.value)"
data-testid="ask-question-option"
@click="selectSingle(opt.value)"
>
<span :class="$style.numberBadge">{{ index + 1 }}</span>
<div :class="$style.optionContent">
<div :class="$style.optionLabel">{{ opt.label }}</div>
<div v-if="opt.description" :class="$style.optionDescription">
{{ opt.description }}
</div>
</div>
</button>
</template>
<template v-else>
<label
v-for="opt in options"
:key="opt.value"
:class="$style.checkboxRow"
data-testid="ask-question-option"
>
<N8nCheckbox
:model-value="selected.includes(opt.value)"
:disabled="disabled"
data-testid="ask-question-checkbox"
@update:model-value="(checked: boolean) => toggleMultiple(opt.value, checked)"
/>
<div :class="$style.optionContent">
<div :class="$style.optionLabel">{{ opt.label }}</div>
<div v-if="opt.description" :class="$style.optionDescription">
{{ opt.description }}
</div>
</div>
</label>
</template>
</div>
<N8nInputLabel
input-name="ask-question-other-input"
:label="freeformLabel"
:bold="false"
size="small"
:class="$style.other"
>
<div :class="$style.otherInputRow">
<N8nInput
id="ask-question-other-input"
v-model="otherText"
size="small"
:disabled="disabled"
:placeholder="freeformPlaceholder"
data-testid="ask-question-other-input"
@keydown="onOtherKeydown"
/>
<N8nButton
v-if="!allowMultiple"
:disabled="!trimmedOtherText || disabled"
size="small"
data-testid="ask-question-other-submit"
@click="submitOther"
>
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
</N8nButton>
</div>
</N8nInputLabel>
<div v-if="allowMultiple" :class="$style.actions">
<N8nButton
:disabled="selectedValuesWithOther.length === 0 || disabled"
size="medium"
data-testid="ask-question-submit"
@click="onSubmit"
>
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
</N8nButton>
</div>
</template>
</div>
</N8nCard>
</template>
<style lang="scss" module>
@use '../../../ai/shared/styles/question-option-rows' as questionOptions;
.card {
--card--padding: var(--spacing--sm);
gap: var(--spacing--xs);
width: 90%;
max-width: 90%;
}
.disabled {
opacity: 0.75;
}
.cardBody {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.question {
margin: 0;
font-size: var(--font-size--sm);
}
.resolved {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
color: var(--color--success);
}
.options {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.other {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
padding-top: var(--spacing--2xs);
}
.otherInputRow {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
:global(.n8n-input) {
flex: 1;
min-width: 0;
}
}
.option {
@include questionOptions.option-button-row;
@include questionOptions.active-selected;
}
.checkboxRow {
@include questionOptions.checkbox-row;
}
.optionDisabled {
cursor: default;
}
.optionLabel {
@include questionOptions.option-label;
}
.optionContent {
min-width: 0;
}
.numberBadge {
@include questionOptions.number-badge;
}
.optionDescription {
font-size: var(--font-size--2xs);
color: var(--color--text);
margin-top: var(--spacing--5xs);
}
.activeSelected {
.optionDescription {
color: var(--color--neutral-white);
}
}
.actions {
display: flex;
justify-content: flex-end;
padding-top: var(--spacing--2xs);
}
</style>
@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* Card for the `ask_questions` builder tool. Reuses `InstanceAiQuestions.vue`
* (the AI assistant's own Q&A wizard) verbatim for the interactive part — the
* two surfaces share the exact same suspend payload shape
* (`questionsSuspendPayloadSchema`), so there is no reason to re-implement
* the wizard here. Only the submit transport differs: this card posts to
* `POST /build/resume` (via the `submit` emit) instead of instance AI's own
* confirm endpoint.
*/
import { computed } from 'vue';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { InteractionQuestion, QuestionAnswer, QuestionsResumeData } from '@n8n/api-types';
import InstanceAiQuestions, {
type QuestionAnswer as WizardAnswer,
} from '@/features/ai/instanceAi/components/InstanceAiQuestions.vue';
import type { QuestionsResolvedValue } from '@/features/ai/shared/agentsChat/types';
const props = defineProps<{
questions: InteractionQuestion[];
introMessage?: string;
disabled?: boolean;
resolvedValue?: QuestionsResolvedValue;
}>();
const emit = defineEmits<{
submit: [resumeData: QuestionsResumeData];
}>();
const i18n = useI18n();
function onSubmit(answers: WizardAnswer[]) {
emit('submit', {
approved: true,
answers: answers.map(({ questionId, selectedOptions, customText, skipped }) => ({
questionId,
selectedOptions,
...(customText ? { customText } : {}),
...(skipped ? { skipped } : {}),
})),
});
}
// ---------------------------------------------------------------------------
// Resolved (disabled) state
// ---------------------------------------------------------------------------
const resolvedAnswers = computed<QuestionAnswer[] | undefined>(() => {
const value = props.resolvedValue;
if (!value || !('answers' in value)) return undefined;
return value.answers;
});
const isAnswered = computed(() => {
const value = props.resolvedValue;
if (!value) return false;
if ('answered' in value) return value.answered;
if ('approved' in value && value.approved === false) return false;
return resolvedAnswers.value !== undefined;
});
interface ResolvedAnswerRow {
question: string;
label: string;
skipped: boolean;
}
const resolvedRows = computed<ResolvedAnswerRow[]>(() => {
const answers = resolvedAnswers.value;
if (!answers) return [];
return props.questions.map((question) => {
const answer = answers.find((a) => a.questionId === question.id);
if (!answer || answer.skipped) {
return { question: question.question, label: '', skipped: true };
}
const parts = [...answer.selectedOptions, ...(answer.customText ? [answer.customText] : [])];
return { question: question.question, label: parts.join(', '), skipped: parts.length === 0 };
});
});
</script>
<template>
<div data-testid="ask-questions-card">
<InstanceAiQuestions
v-if="!disabled"
:questions="questions"
:intro-message="introMessage"
@submit="onSubmit"
/>
<div v-else :class="$style.resolved">
<template v-if="isAnswered && resolvedRows.length > 0">
<div v-for="row in resolvedRows" :key="row.question" :class="$style.row">
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small">
<strong>{{ row.question }}:</strong>
{{ row.skipped ? i18n.baseText('agents.chat.askQuestions.skipped') : row.label }}
</N8nText>
</div>
</template>
<N8nText v-else size="small" color="text-light">
{{ i18n.baseText('agents.chat.askQuestions.skipped') }}
</N8nText>
</div>
</div>
</template>
<style lang="scss" module>
.resolved {
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
width: 90%;
max-width: 90%;
}
.row {
display: flex;
align-items: baseline;
gap: var(--spacing--2xs);
}
</style>
@@ -0,0 +1,81 @@
<script lang="ts" setup>
/**
* Card for the `configure_channel` builder tool. Thin transport adapter
* around the shared `ChannelSetupCard` (body + composable wiring lives
* there, identical to `InstanceAiChannelSetup.vue`'s) — this surface only
* translates the shared `resolve` event into the agents-chat resume
* transport (`submit` emit → `POST /build/resume` with `{ approved }`) and
* renders the collapsed resolved-state summary once disabled.
*/
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { ChannelResumeData } from '@n8n/api-types';
import { computed, ref } from 'vue';
import ChannelSetupCard from '@/features/ai/shared/components/ChannelSetupCard.vue';
import type { ChannelResolvedValue } from '@/features/ai/shared/agentsChat/types';
const props = defineProps<{
integrationType: string;
agentId: string;
projectId: string;
disabled?: boolean;
resolvedValue?: ChannelResolvedValue;
}>();
const emit = defineEmits<{
submit: [resumeData: ChannelResumeData];
}>();
const submitted = ref(false);
function onResolve({ approved }: { approved: boolean }) {
if (submitted.value || props.disabled) return;
submitted.value = true;
emit('submit', { approved });
}
// ---------------------------------------------------------------------------
// Resolved (disabled) state
// ---------------------------------------------------------------------------
const i18n = useI18n();
const isChannelConnected = computed(() => {
const value = props.resolvedValue;
if (!value) return false;
return 'connected' in value ? value.connected : value.approved;
});
</script>
<template>
<ChannelSetupCard
v-if="!disabled"
data-testid="configure-channel-card"
:integration-type="integrationType"
:agent-id="agentId"
:project-id="projectId"
:disabled="submitted"
@resolve="onResolve"
/>
<div v-else :class="$style.resolvedRow" data-testid="configure-channel-card">
<template v-if="isChannelConnected">
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small">{{ i18n.baseText('agents.channels.modal.connected') }}</N8nText>
</template>
<template v-else>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.chat.configureChannel.skipped') }}
</N8nText>
</template>
</div>
</template>
<style lang="scss" module>
.resolvedRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
</style>
@@ -4,29 +4,36 @@ import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
} from '@n8n/api-types';
import type {
AgentsChatInteractionContext,
AgentsChatInteractionRenderer,
} from '@/features/ai/shared/agentsChat/interactionRegistry';
import type { AgentsChatInteractionRenderer } from '@/features/ai/shared/agentsChat/interactionRegistry';
import InteractionRenderer from '@/features/ai/shared/agentsChat/components/InteractionRenderer.vue';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import AskCredentialCard from './AskCredentialCard.vue';
import AskLlmCard from './AskLlmCard.vue';
import AskQuestionCard from './AskQuestionCard.vue';
import AskQuestionsCard from './AskQuestionsCard.vue';
import ApprovalCard from './ApprovalCard.vue';
import ConfigureChannelCard from './ConfigureChannelCard.vue';
import N8nChatActionCard from './N8nChatActionCard.vue';
/**
* Single dispatch point for the interactive cards. Switches by `toolName` so
* `AgentChatMessageList` doesn't repeat the narrowing helpers / non-null
* assertions for every per-card branch.
* Single dispatch point for the interactive cards. `approval` and
* `chat_action` still dispatch by `toolName` (their payload shape isn't
* shared with any other surface). `ask_questions`, `ask_credential` /
* `ask_embedding_credential`, and `configure_channel` MATCH by the PAYLOAD
* FIELD that's unique to their suspend schema
* (`inputType`/`credentialRequests`/`channelConfig`) — this is the same
* shared instance-AI-compatible contract those three suspend with
* (`agent-interaction.schema.ts`), so matching on it means the agents-builder
* chat and the AI assistant render the identical card for the identical
* payload without a per-surface translation step. `getProps` still narrows
* via `toolName` (a 1:1, schema-guaranteed correspondence with the payload
* field) since that's the more reliable TS discriminant.
*
* `projectId` / `agentId` are only required when rendering AskCredentialCard
* (which talks to the credentials API). Other cards ignore them.
* `projectId` / `agentId` are only required when rendering the channel card
* (which talks to the integrations API using them directly). The credential
* card also accepts `projectId` but works without it.
*/
const props = defineProps<{
payload: InteractivePayload;
@@ -46,10 +53,33 @@ const emit = defineEmits<{
*/
const disabled = computed(() => !!props.payload.resolvedAt || !props.payload.runId);
function hasCredentialContext(
context: AgentsChatInteractionContext | undefined,
): context is AgentsChatInteractionContext & { projectId: string; agentId: string } {
return typeof context?.projectId === 'string' && typeof context.agentId === 'string';
/**
* Presence checks also confirm `toolName`, not just the input shape. Without
* it, a malformed/corrupted payload whose `toolName` doesn't correspond to
* the field it happens to carry would still `match()` here, then fail to
* narrow in `getProps` (which discriminates strictly on `toolName`) and hand
* the card `{}` — missing required props it doesn't guard against. Tying the
* two together means a mismatch fails `matches()` and falls through to "no
* renderer" (nothing rendered) instead of a props-shape crash.
*/
function hasQuestionsInput(payload: InteractivePayload): boolean {
return (
payload.toolName === ASK_QUESTIONS_TOOL_NAME &&
'inputType' in payload.input &&
payload.input.inputType === 'questions'
);
}
function hasCredentialRequestsInput(payload: InteractivePayload): boolean {
return (
(payload.toolName === ASK_CREDENTIAL_TOOL_NAME ||
payload.toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) &&
'credentialRequests' in payload.input
);
}
function hasChannelConfigInput(payload: InteractivePayload): boolean {
return payload.toolName === CONFIGURE_CHANNEL_TOOL_NAME && 'channelConfig' in payload.input;
}
const interactiveRenderers = [
@@ -65,67 +95,48 @@ const interactiveRenderers = [
};
},
},
{
key: 'ask_questions',
component: AskQuestionsCard,
matches: (payload) => hasQuestionsInput(payload),
getProps: (payload) => {
if (payload.toolName !== ASK_QUESTIONS_TOOL_NAME) return {};
return {
questions: payload.input.questions,
introMessage: payload.input.introMessage,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'ask_credential',
component: AskCredentialCard,
matches: (payload, context) =>
payload.toolName === ASK_CREDENTIAL_TOOL_NAME && hasCredentialContext(context),
matches: (payload) => hasCredentialRequestsInput(payload),
getProps: (payload, context) => {
if (payload.toolName !== ASK_CREDENTIAL_TOOL_NAME || !hasCredentialContext(context))
if (
payload.toolName !== ASK_CREDENTIAL_TOOL_NAME &&
payload.toolName !== ASK_EMBEDDING_CREDENTIAL_TOOL_NAME
) {
return {};
}
return {
purpose: payload.input.purpose,
credentialType: payload.input.credentialType,
nodeType: payload.input.nodeType,
credentialSlot: payload.input.credentialSlot,
projectId: context.projectId,
agentId: context.agentId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'ask_embedding_credential',
component: AskCredentialCard,
matches: (payload, context) =>
payload.toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME && hasCredentialContext(context),
getProps: (payload, context) => {
if (payload.toolName !== ASK_EMBEDDING_CREDENTIAL_TOOL_NAME || !hasCredentialContext(context))
return {};
return {
purpose: payload.input.purpose,
credentialType: payload.input.credentialType,
nodeType: payload.input.nodeType,
credentialSlot: payload.input.credentialSlot,
projectId: context.projectId,
agentId: context.agentId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'ask_llm',
component: AskLlmCard,
matches: (payload) => payload.toolName === ASK_LLM_TOOL_NAME,
getProps: (payload, context) => {
if (payload.toolName !== ASK_LLM_TOOL_NAME) return {};
return {
purpose: payload.input.purpose,
resolvedValue: payload.resolvedValue,
credentialRequests: payload.input.credentialRequests,
message: payload.input.message,
projectId: context?.projectId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'ask_question',
component: AskQuestionCard,
matches: (payload) => payload.toolName === ASK_QUESTION_TOOL_NAME,
key: 'configure_channel',
component: ConfigureChannelCard,
matches: (payload) => hasChannelConfigInput(payload),
getProps: (payload) => {
if (payload.toolName !== ASK_QUESTION_TOOL_NAME) return {};
if (payload.toolName !== CONFIGURE_CHANNEL_TOOL_NAME) return {};
return {
question: payload.input.question,
options: payload.input.options,
allowMultiple: payload.input.allowMultiple,
integrationType: payload.input.channelConfig.integrationType,
agentId: payload.input.channelConfig.agentId,
projectId: payload.input.projectId,
resolvedValue: payload.resolvedValue,
};
},
@@ -124,7 +124,11 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
openSuspensions = envelope.openSuspensions;
}
if (dbMessages.length > 0) {
messages.value = applyOpenSuspensions(convertDbMessages(dbMessages), openSuspensions);
const context = { agentId: params.agentId.value, projectId: params.projectId.value };
messages.value = applyOpenSuspensions(
convertDbMessages(dbMessages, context),
openSuspensions,
);
}
params.onHistoryLoaded?.(messages.value.length);
} catch (error) {
@@ -1,7 +1,7 @@
/**
* Model identifier helpers. The canonical storage format is `"<provider>/<name>"`.
* Centralised here because three callers (Agent panel, Advanced panel, AskLlm
* card) used to roll their own and drifted on naming + edge cases.
* Centralised here because multiple callers (Agent info panel, memory panel,
* sub-agents panel) used to roll their own and drifted on naming + edge cases.
*/
export interface ParsedModel {
@@ -1,4 +1,4 @@
import { waitFor } from '@testing-library/vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
@@ -8,161 +8,30 @@ import InstanceAiChannelSetup from '../components/InstanceAiChannelSetup.vue';
import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
import { createThreadComponentRenderer } from './createThreadComponentRenderer';
const mocks = vi.hoisted(() => {
const slackIntegration = {
type: 'slack',
label: 'Slack',
icon: 'slack',
credentialTypes: ['slackOAuth2Api'],
};
return {
slackIntegration,
ensureLoaded: vi.fn(),
fetchStatus: vi.fn(),
connect: vi.fn(),
createSlackAgentApp: vi.fn(),
};
});
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string, opts?: { interpolate?: Record<string, string> }) => {
if (opts?.interpolate) {
return Object.entries(opts.interpolate).reduce(
(str, [k, v]) => str.replace(`{${k}}`, v),
key,
);
}
return key;
},
}),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({
restApiContext: {},
}),
}));
vi.mock('@n8n/permissions', () => ({
getResourcePermissions: () => ({
credential: {
create: true,
read: true,
update: true,
delete: true,
share: true,
move: true,
},
}),
}));
vi.mock('@/features/agents/composables/useAgentIntegrationsCatalog', async () => {
const { ref } = await import('vue');
return {
useAgentIntegrationsCatalog: () => ({
catalog: ref([mocks.slackIntegration]),
ensureLoaded: mocks.ensureLoaded,
}),
};
});
vi.mock('@/features/agents/composables/useAgentIntegrationStatus', async () => {
const { ref } = await import('vue');
return {
useAgentIntegrationStatus: () => ({
connectedCredentials: ref<Record<string, string>>({}),
integrationSettings: ref({}),
loadingMap: ref<Record<string, boolean>>({}),
errorMessages: ref<Record<string, string>>({}),
errorIsConflict: ref<Record<string, boolean>>({}),
fetchStatus: mocks.fetchStatus,
connect: mocks.connect,
isConnected: () => false,
}),
};
});
vi.mock('@/features/agents/composables/useAgentApi', () => ({
createSlackAgentApp: mocks.createSlackAgentApp,
getAgent: vi.fn(),
}));
vi.mock('@/features/agents/components/AgentChannelModal.vue', () => ({
/**
* `InstanceAiChannelSetup` is a thin transport adapter around the shared
* `ChannelSetupCard` (body + composable wiring, tested on its own in
* `features/ai/shared/components/ChannelSetupCard.test.ts`). Here we only
* prove the adapter's own job: mapping the shared `resolve` event onto
* instance AI's confirm transport (`confirmAction` + `resolveConfirmation`,
* with the MAX_CONFIRM_ATTEMPTS retry semantics) and gating a duplicate
* resolve when the request is already resolved.
*/
vi.mock('@/features/ai/shared/components/ChannelSetupCard.vue', () => ({
default: {
template: '<div data-test-id="agent-channel-modal" />',
props: ['integrationType', 'agentId', 'projectId', 'disabled'],
emits: ['resolve'],
// No hardcoded `data-test-id` on the root: the adapter passes its own
// (`instance-ai-channel-setup`) as a fallthrough attribute, which would
// just overwrite one set here anyway.
template:
'<div :data-disabled="disabled">' +
'<button data-test-id="mock-resolve-approved" @click="$emit(\'resolve\', { approved: true })" />' +
'<button data-test-id="mock-resolve-skipped" @click="$emit(\'resolve\', { approved: false })" />' +
'</div>',
},
}));
vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', async () => {
const { ref } = await import('vue');
return {
default: {
props: [
'modelValue',
'mode',
'connected',
'isPublished',
'setupSlackApp',
'projectId',
'agentId',
'integration',
'credentials',
'credentialPermissions',
'credentialsLoading',
'loading',
'errorMessage',
'errorIsConflict',
'forceNewCredential',
'setupMode',
],
emits: ['update:modelValue', 'connect', 'create', 'edit'],
setup(props: { setupSlackApp?: (appConfigurationToken: string) => Promise<boolean> }) {
const setupStatus = ref('idle');
const runSlackAppSetup = async () => {
setupStatus.value = 'loading';
try {
await props.setupSlackApp?.('app-token');
setupStatus.value = 'connected';
} catch {
setupStatus.value = 'error';
}
};
return { setupStatus, runSlackAppSetup };
},
template: `
<div data-test-id="mock-slack-setup" :data-setup-mode="setupMode">
<button
data-test-id="mock-slack-connect"
@click="$emit('update:modelValue', 'cred-1'); $emit('connect')"
>
Connect Slack
</button>
<button
data-test-id="mock-slack-connect-twice"
@click="$emit('update:modelValue', 'cred-1'); $emit('connect'); $emit('connect')"
>
Connect Slack Twice
</button>
<button
data-test-id="mock-slack-app-setup"
@click="runSlackAppSetup"
>
Install Slack app
</button>
<span data-test-id="mock-slack-app-setup-status">{{ setupStatus }}</span>
</div>
`,
},
};
});
const renderComponent = createThreadComponentRenderer(InstanceAiChannelSetup);
const defaultProps = {
@@ -177,12 +46,6 @@ describe('InstanceAiChannelSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureLoaded.mockResolvedValue([mocks.slackIntegration]);
mocks.fetchStatus.mockResolvedValue(undefined);
mocks.connect.mockResolvedValue({ status: 'connected' });
mocks.createSlackAgentApp.mockResolvedValue({
installUrl: 'https://slack.com/oauth/install',
});
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
@@ -191,102 +54,34 @@ describe('InstanceAiChannelSetup', () => {
thread.resolvedConfirmationIds.clear();
});
it('renders Slack setup inline and resumes the confirmation when connected', async () => {
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
const { getByTestId, queryByTestId } = renderComponent({
props: defaultProps,
});
it('renders the shared channel-setup card', () => {
const { getByTestId } = renderComponent({ props: defaultProps });
expect(getByTestId('instance-ai-channel-setup')).toBeInTheDocument();
expect(getByTestId('mock-slack-setup')).toHaveAttribute('data-setup-mode', 'simple');
expect(queryByTestId('agent-channel-modal')).toBeNull();
await userEvent.click(getByTestId('mock-slack-connect'));
await waitFor(() => expect(mocks.connect).toHaveBeenCalledWith('slack', 'cred-1', undefined));
expect(confirmSpy).toHaveBeenCalledWith('req-channel', {
kind: 'approval',
approved: true,
});
expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'approved');
});
it('submits deferred when the user skips setup', async () => {
it('confirms and resolves approved when the shared card resolves connected', async () => {
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('instance-ai-channel-setup-skip'));
await userEvent.click(getByTestId('mock-resolve-approved'));
expect(confirmSpy).toHaveBeenCalledWith('req-channel', {
kind: 'approval',
approved: false,
});
expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'deferred');
await waitFor(() => expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'approved'));
expect(confirmSpy).toHaveBeenCalledWith('req-channel', { kind: 'approval', approved: true });
});
it('does not submit twice when setup emits connect twice', async () => {
it('confirms and resolves deferred when the shared card resolves skipped', async () => {
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const resolveSpy = vi.spyOn(thread, 'resolveConfirmation');
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-connect-twice'));
await userEvent.click(getByTestId('mock-resolve-skipped'));
await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1));
expect(mocks.connect).toHaveBeenCalledTimes(1);
expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(confirmSpy).toHaveBeenCalledWith('req-channel', {
kind: 'approval',
approved: true,
});
});
it('keeps skip disabled while channel connection is in flight', async () => {
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
let resolveConnect: (value: { status: string }) => void = () => {};
mocks.connect.mockReturnValueOnce(
new Promise((resolve) => {
resolveConnect = resolve;
}),
);
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-connect'));
await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1));
expect(getByTestId('instance-ai-channel-setup-skip')).toBeDisabled();
await userEvent.click(getByTestId('instance-ai-channel-setup-skip'));
expect(confirmSpy).not.toHaveBeenCalledWith('req-channel', {
kind: 'approval',
approved: false,
});
resolveConnect({ status: 'connected' });
await waitFor(() =>
expect(confirmSpy).toHaveBeenCalledWith('req-channel', {
kind: 'approval',
approved: true,
}),
);
});
it('fails Slack app setup immediately when the authorization popup is blocked', async () => {
vi.spyOn(window, 'open').mockReturnValueOnce(null);
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-app-setup'));
await waitFor(() =>
expect(getByTestId('mock-slack-app-setup-status')).toHaveTextContent('error'),
);
expect(confirmSpy).not.toHaveBeenCalled();
await waitFor(() => expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'deferred'));
expect(confirmSpy).toHaveBeenCalledWith('req-channel', { kind: 'approval', approved: false });
});
it('retries a failed confirmAction once before resolving', async () => {
@@ -298,7 +93,7 @@ describe('InstanceAiChannelSetup', () => {
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-connect'));
await userEvent.click(getByTestId('mock-resolve-approved'));
await waitFor(() => expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'approved'));
expect(confirmSpy).toHaveBeenCalledTimes(2);
@@ -310,22 +105,37 @@ describe('InstanceAiChannelSetup', () => {
const { getByTestId, queryByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-connect'));
await userEvent.click(getByTestId('mock-resolve-approved'));
await waitFor(() => expect(resolveSpy).toHaveBeenCalledWith('req-channel', 'approved'));
expect(confirmSpy).toHaveBeenCalledTimes(2);
expect(queryByTestId('instance-ai-channel-setup')).toBeNull();
});
it('ignores submit when request is already resolved', async () => {
it('does not resolve twice for a duplicate resolve event', async () => {
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent({ props: defaultProps });
const button = getByTestId('mock-resolve-approved');
// Fire both synchronously (no await between) to prove the adapter's
// own guard — not just the shared card's — blocks a duplicate resolve.
await fireEvent.click(button);
await fireEvent.click(button);
await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1));
});
it('passes disabled=true to the shared card and ignores a resolve event once the request is already resolved', async () => {
thread.resolveConfirmation('req-channel', 'approved');
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent({ props: defaultProps });
await userEvent.click(getByTestId('mock-slack-connect'));
expect(getByTestId('instance-ai-channel-setup')).toHaveAttribute('data-disabled', 'true');
await userEvent.click(getByTestId('mock-resolve-approved'));
expect(mocks.connect).not.toHaveBeenCalled();
expect(confirmSpy).not.toHaveBeenCalled();
});
});
@@ -1,19 +1,15 @@
<script lang="ts" setup>
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { useI18n } from '@n8n/i18n';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, watch } from 'vue';
/**
* Thin transport adapter around the shared `ChannelSetupCard` (body +
* composable wiring lives there, identical to agents-chat's
* `ConfigureChannelCard.vue`) this surface only translates the shared
* `resolve` event into instance AI's own confirm transport
* (`thread.confirmAction` + `thread.resolveConfirmation`, with the
* MAX_CONFIRM_ATTEMPTS retry semantics this surface has always had).
*/
import { computed, ref } from 'vue';
import { getAgent } from '@/features/agents/composables/useAgentApi';
import { useAgentChannelSetup } from '@/features/agents/composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '@/features/agents/composables/useAgentIntegrationStatus';
import { useAgentIntegrationsCatalog } from '@/features/agents/composables/useAgentIntegrationsCatalog';
import AgentChannelLinearSetup from '@/features/agents/components/AgentChannelLinearSetup.vue';
import AgentChannelSlackSetup from '@/features/agents/components/AgentChannelSlackSetup.vue';
import AgentChannelTelegramSetup from '@/features/agents/components/AgentChannelTelegramSetup.vue';
import type { AgentResource } from '@/features/agents/types';
import ChannelSetupCard from '@/features/ai/shared/components/ChannelSetupCard.vue';
import { useThread } from '../instanceAi.store';
@@ -25,296 +21,39 @@ const props = defineProps<{
}>();
const thread = useThread();
const i18n = useI18n();
const rootStore = useRootStore();
const { catalog, ensureLoaded } = useAgentIntegrationsCatalog();
const {
fetchStatus,
connectedCredentials,
integrationSettings,
loadingMap,
errorMessages,
errorIsConflict,
isConnected: isIntegrationConnected,
connect,
} = useAgentIntegrationStatus(props.projectId, props.agentId);
const MAX_CONFIRM_ATTEMPTS = 2;
const submitted = ref(false);
const connectionInFlight = ref(false);
const agent = ref<AgentResource | null>(null);
const currentIntegration = computed<ChatIntegrationDescriptor | null>(() => {
return catalog.value?.find((integration) => integration.type === props.integrationType) ?? null;
});
const {
channelSetupRef,
selectedCredentials,
credentialsLoading,
credentialPermissions,
getChannelCredentialId,
getCredentials,
loadChannelState: loadSharedChannelState,
createCredential,
editCredential,
setupSlackApp: runSlackAppSetup,
} = useAgentChannelSetup({
projectId: () => props.projectId,
agentId: () => props.agentId,
currentIntegration,
connectedCredentials,
fetchStatus,
isIntegrationConnected,
});
const integrationLabel = computed(() => currentIntegration.value?.label ?? props.integrationType);
const connectedDescriptionKeys = {
telegram: 'agents.builder.addTrigger.connectedText.telegram',
linear: 'agents.builder.addTrigger.connectedText.linear',
} as const;
const connectedDescription = computed(() => {
const key =
connectedDescriptionKeys[props.integrationType as keyof typeof connectedDescriptionKeys];
return key ? i18n.baseText(key) : '';
});
const currentChannelCredentialId = computed(() => getChannelCredentialId(props.integrationType));
const currentCredentials = computed(() => getCredentials(props.integrationType));
const isConnected = computed(() => isIntegrationConnected(props.integrationType));
const isLoading = computed(() => loadingMap.value[props.integrationType] ?? false);
const errorMessage = computed(() => errorMessages.value[props.integrationType] ?? '');
const hasUnsupportedIntegration = computed(
() => !['slack', 'telegram', 'linear'].includes(props.integrationType),
// Extra external gate passed to the shared component: guards against a
// stray resolve slipping through when the request was already resolved by
// another path (e.g. a concurrent confirmation), independent of this
// adapter's own `submitted` guard below.
const isResolvedOrSubmitted = computed(
() => submitted.value || thread.resolvedConfirmationIds.has(props.requestId),
);
const cardTitle = computed(() =>
i18n.baseText('agents.channels.modal.connectTitle', {
interpolate: { channel: integrationLabel.value },
}),
);
function toIconName(icon: string): IconName {
return icon as IconName;
}
function isResolvedOrSubmitted() {
return submitted.value || thread.resolvedConfirmationIds.has(props.requestId);
}
function finish(approved: boolean, resolution: 'approved' | 'deferred') {
if (isResolvedOrSubmitted()) return;
async function onResolve({ approved }: { approved: boolean }) {
if (isResolvedOrSubmitted.value) return;
submitted.value = true;
void submitConfirmation(approved, resolution);
}
function skipSetup() {
if (connectionInFlight.value) return;
finish(false, 'deferred');
}
async function submitConfirmation(approved: boolean, resolution: 'approved' | 'deferred') {
const resolution = approved ? 'approved' : 'deferred';
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt++) {
if (await thread.confirmAction(props.requestId, { kind: 'approval', approved })) break;
}
thread.resolveConfirmation(props.requestId, resolution);
}
async function saveChannelConfig() {
if (isResolvedOrSubmitted() || connectionInFlight.value) return;
const credentialId = currentChannelCredentialId.value;
if (!credentialId || channelSetupRef.value?.validationError) return;
connectionInFlight.value = true;
try {
await connect(props.integrationType, credentialId, channelSetupRef.value?.currentSettings);
finish(true, 'approved');
} catch {
// useAgentIntegrationStatus exposes the connection error to the setup component.
} finally {
connectionInFlight.value = false;
}
}
async function setupSlackApp(appConfigurationToken: string): Promise<boolean> {
if (isResolvedOrSubmitted() || connectionInFlight.value) return false;
connectionInFlight.value = true;
try {
return await runSlackAppSetup(appConfigurationToken, () => finish(true, 'approved'));
} finally {
connectionInFlight.value = false;
}
}
async function loadChannelState() {
const integrations = await ensureLoaded(props.projectId).catch(() => catalog.value ?? []);
await loadSharedChannelState(integrations);
if (props.integrationType !== 'slack') {
try {
agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId);
} catch {
agent.value = null;
}
}
}
watch(
() => [props.projectId, props.agentId, props.integrationType] as const,
() => void loadChannelState(),
{ immediate: true },
);
</script>
<template>
<div v-if="!submitted" :class="$style.card" data-test-id="instance-ai-channel-setup">
<header :class="$style.header">
<N8nIcon
v-if="currentIntegration?.icon"
:icon="toIconName(currentIntegration.icon)"
size="medium"
/>
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
{{ cardTitle }}
</N8nText>
</header>
<div :class="$style.bodyWrapper">
<AgentChannelSlackSetup
v-if="integrationType === 'slack'"
ref="channelSetupRef"
v-model="selectedCredentials.slack"
mode="setup"
:connected="isConnected"
:is-published="false"
:setup-slack-app="setupSlackApp"
:project-id="projectId"
:agent-id="agentId"
:integration="currentIntegration ?? undefined"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict.slack"
:force-new-credential="true"
setup-mode="simple"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelLinearSetup
v-else-if="currentIntegration?.type === 'linear'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConnected"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="false"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelTelegramSetup
v-else-if="currentIntegration?.type === 'telegram'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConnected"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="false"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<N8nText v-else-if="hasUnsupportedIntegration" size="small" color="text-light">
{{
i18n.baseText('agents.channels.modal.setupPlaceholder', {
interpolate: { channel: integrationLabel },
})
}}
</N8nText>
</div>
<footer :class="$style.footer">
<N8nButton
variant="ghost"
size="medium"
:disabled="connectionInFlight"
data-test-id="instance-ai-channel-setup-skip"
@click="skipSetup"
>
{{ i18n.baseText('instanceAi.workflowSetup.later') }}
</N8nButton>
</footer>
</div>
<ChannelSetupCard
v-if="!submitted"
data-test-id="instance-ai-channel-setup"
:integration-type="integrationType"
:agent-id="agentId"
:project-id="projectId"
:disabled="isResolvedOrSubmitted"
@resolve="onResolve"
/>
</template>
<style lang="scss" module>
.card {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding-top: var(--spacing--sm);
border: 2px solid var(--color--primary);
border-radius: var(--radius--lg);
background-color: var(--background--surface);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--sm);
text-transform: capitalize;
}
.title {
flex: 1;
}
.bodyWrapper {
padding: 0 var(--spacing--sm);
}
.footer {
display: flex;
justify-content: flex-end;
padding: 0 var(--spacing--sm) var(--spacing--sm);
}
</style>
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
type AgentPersistedMessageDto,
} from '@n8n/api-types';
@@ -20,9 +21,9 @@ describe('shared agents chat message mapping', () => {
content: [
{
type: 'tool-call',
toolName: ASK_LLM_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'call-1',
input: { purpose: 'main model' },
input: { questions: [{ question: 'Which model?', type: 'text' }] },
state: 'pending',
},
],
@@ -32,7 +33,7 @@ describe('shared agents chat message mapping', () => {
const chat = convertDbMessages(dbMessages);
expect(chat[0].status).toBe('awaitingUser');
expect(chat[0].interactive?.toolName).toBe(ASK_LLM_TOOL_NAME);
expect(chat[0].interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(chat[0].toolCalls?.[0].state).toBe('suspended');
});
@@ -44,9 +45,12 @@ describe('shared agents chat message mapping', () => {
content: [
{
type: 'tool-call',
toolName: ASK_QUESTION_TOOL_NAME,
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'question-1',
input: { question: 'Pick one', options: [{ label: 'A', value: 'a' }] },
// Persisted history only ever carries the tool's original call
// args, never the (transient, SSE-only) suspend payload —
// `rebuildInteractiveFromHistory` synthesizes the rest.
input: { questions: [{ question: 'Pick one', type: 'single', options: ['a'] }] },
state: 'pending',
},
],
@@ -58,16 +62,106 @@ describe('shared agents chat message mapping', () => {
expect(chat[0].interactive?.runId).toBe('run-1');
});
it('rebuilds resolved ask_question cards from tool output', () => {
it('rebuilds resolved ask_questions cards from tool output', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_QUESTION_TOOL_NAME,
tool: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'question-2',
input: { question: 'Pick one', options: [{ label: 'A', value: 'a' }] },
output: { values: ['a'] },
input: { questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['a'] }] },
output: { answered: true, answers: [{ questionId: 'q1', selectedOptions: ['a'] }] },
state: 'done',
});
expect(result?.resolvedAt).toBeDefined();
expect(result?.resolvedValue).toEqual({ values: ['a'] });
expect(result?.resolvedValue).toEqual({
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['a'] }],
});
});
it('reconstructs an open ask_credential card from raw tool args', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'cred-1',
input: { purpose: 'Slack access', credentialType: 'slackApi' },
state: 'pending',
});
if (result?.toolName !== ASK_CREDENTIAL_TOOL_NAME) throw new Error('expected ask_credential');
expect(result.input.message).toBe('Slack access');
expect(result.input.credentialRequests[0].credentialType).toBe('slackApi');
expect(result.input.credentialRequests[0].existingCredentials).toEqual([]);
expect(result.resolvedAt).toBeUndefined();
});
it('reconstructs a resolved ask_credential card', () => {
const resolved = rebuildInteractiveFromHistory({
tool: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'cred-2',
input: { purpose: 'Slack access', credentialType: 'slackApi' },
output: { credentialId: 'c1', credentialName: 'My Slack' },
state: 'done',
});
expect(resolved?.resolvedAt).toBeDefined();
expect(resolved?.resolvedValue).toMatchObject({ credentialName: 'My Slack' });
const skipped = rebuildInteractiveFromHistory({
tool: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'cred-3',
input: { purpose: 'Slack access', credentialType: 'slackApi' },
output: { skipped: true },
state: 'done',
});
expect(skipped?.resolvedValue).toEqual({ skipped: true });
});
it('reconstructs an open configure_channel card only when ambient context is supplied', () => {
const withContext = rebuildInteractiveFromHistory(
{
tool: CONFIGURE_CHANNEL_TOOL_NAME,
toolCallId: 'channel-1',
input: { integrationType: 'slack' },
state: 'pending',
},
{ agentId: 'a1', projectId: 'p1' },
);
if (withContext?.toolName !== CONFIGURE_CHANNEL_TOOL_NAME) {
throw new Error('expected configure_channel');
}
expect(withContext.input.channelConfig).toEqual({ integrationType: 'slack', agentId: 'a1' });
expect(withContext.input.projectId).toBe('p1');
const withoutContext = rebuildInteractiveFromHistory({
tool: CONFIGURE_CHANNEL_TOOL_NAME,
toolCallId: 'channel-2',
input: { integrationType: 'slack' },
state: 'pending',
});
expect(withoutContext).toBeUndefined();
});
it('parses both configure_channel resolved output shapes', () => {
const connected = rebuildInteractiveFromHistory(
{
tool: CONFIGURE_CHANNEL_TOOL_NAME,
toolCallId: 'channel-3',
input: { integrationType: 'slack' },
output: { connected: true },
state: 'done',
},
{ agentId: 'a1', projectId: 'p1' },
);
expect(connected?.resolvedValue).toEqual({ connected: true });
const approved = rebuildInteractiveFromHistory(
{
tool: CONFIGURE_CHANNEL_TOOL_NAME,
toolCallId: 'channel-4',
input: { integrationType: 'slack' },
output: { approved: false },
state: 'done',
},
{ agentId: 'a1', projectId: 'p1' },
);
expect(approved?.resolvedValue).toEqual({ approved: false });
});
});
@@ -1,14 +1,9 @@
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskLlmResume,
type AskQuestionInput,
type AskQuestionResume,
} from '@n8n/api-types';
import {
@@ -20,7 +15,7 @@ import {
/**
* Build a one-line human-readable label for a resolved interactive tool call.
* Used by `AgentChatToolSteps` to show the user's answer beside the tool name
* (e.g. "→ ask_question · Slack") so resolved cards leave a compact trace in
* (e.g. "→ ask_questions · Slack") so resolved cards leave a compact trace in
* scrollback instead of vanishing.
*
* Returns `undefined` for non-interactive tools or when the output isn't
@@ -30,6 +25,27 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
interface QuestionAnswerLike {
selectedOptions?: unknown;
customText?: unknown;
skipped?: unknown;
}
function isQuestionAnswerLike(value: unknown): value is QuestionAnswerLike {
return isPlainObject(value);
}
/** One-line label for a single answered question: joined selected options, or the free-text answer. */
function answerLabel(answer: QuestionAnswerLike): string | undefined {
if (answer.skipped === true) return undefined;
const selected = Array.isArray(answer.selectedOptions)
? answer.selectedOptions.filter((v): v is string => typeof v === 'string')
: [];
const customText = typeof answer.customText === 'string' ? answer.customText.trim() : '';
const parts = customText ? [...selected, customText] : selected;
return parts.length > 0 ? parts.join(', ') : undefined;
}
export function summariseInteractiveOutput(
toolName: string,
output: unknown,
@@ -40,34 +56,37 @@ export function summariseInteractiveOutput(
// throwing when a malformed payload sneaks through.
if (!isPlainObject(output)) return undefined;
if (toolName === ASK_QUESTION_TOOL_NAME) {
const resume = output as AskQuestionResume;
if (!Array.isArray(resume.values) || resume.values.length === 0) return undefined;
const opts = (input as AskQuestionInput | undefined)?.options ?? [];
const labels = resume.values.map((v) => opts.find((o) => o.value === v)?.label ?? v);
return labels.join(', ');
if (toolName === ASK_QUESTIONS_TOOL_NAME) {
const answers = Array.isArray(output.answers) ? output.answers : undefined;
if (!answers || answers.length === 0) return undefined;
const labels = answers.filter(isQuestionAnswerLike).map(answerLabel).filter(Boolean);
return labels.length > 0 ? labels.join('; ') : undefined;
}
if (toolName === ASK_CREDENTIAL_TOOL_NAME) {
const resume = output as AskCredentialResume;
if ('skipped' in resume && resume.skipped) return 'Skipped';
if ('credentialName' in resume && resume.credentialName) return resume.credentialName;
if (toolName === ASK_CREDENTIAL_TOOL_NAME || toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
if ('skipped' in output && output.skipped) return 'Skipped';
if (typeof output.credentialName === 'string' && output.credentialName) {
return output.credentialName;
}
if (isPlainObject(output.credentials) && Object.keys(output.credentials).length > 0) {
return 'Selected';
}
return undefined;
}
if (toolName === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
const resume = output as AskEmbeddingCredentialResume;
if ('skipped' in resume && resume.skipped) return 'Skipped';
if ('credential' in resume && resume.credential === 'managed') return 'Managed by n8n';
if ('credentialName' in resume && resume.credentialName) return resume.credentialName;
return undefined;
}
if (toolName === ASK_LLM_TOOL_NAME) {
const resume = output as AskLlmResume;
if (!resume.provider || !resume.model) return undefined;
const slug = `${resume.provider}/${resume.model}`;
return resume.credentialName ? `${slug} · ${resume.credentialName}` : slug;
if (toolName === CONFIGURE_CHANNEL_TOOL_NAME) {
// `output` is the real tool result (`{ connected }`) once settled, but
// the optimistic update right after resume stores the raw resume
// payload (`{ approved }`) instead — mirrors the `connected`/`approved`
// fallback in `parseChannelResolvedValue` (messageMappers.ts).
const connected =
typeof output.connected === 'boolean'
? output.connected
: typeof output.approved === 'boolean'
? output.approved
: undefined;
if (connected === undefined) return undefined;
return connected ? 'Connected' : 'Skipped';
}
if (toolName === N8N_CHAT_ACTION_TOOL_NAME) {
@@ -1,22 +1,27 @@
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
APPROVAL_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
askCredentialInputSchema,
askCredentialResumeSchema,
askEmbeddingCredentialResumeSchema,
askLlmInputSchema,
askLlmResumeSchema,
askQuestionInputSchema,
askQuestionResumeSchema,
channelResumeSchema,
channelSuspendPayloadSchema,
credentialResumeSchema,
credentialSuspendPayloadSchema,
questionAnswerSchema,
questionsResumeSchema,
questionsSuspendPayloadSchema,
type AgentBuilderOpenSuspension,
type AgentPersistedMessageDto,
type ChannelSuspendPayload,
type CredentialSuspendPayload,
type InteractiveToolName,
type QuestionsSuspendPayload,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { z } from 'zod';
import {
isAwaitingCard,
n8nChatResumeValueSchema,
@@ -29,21 +34,30 @@ import { isFailedDelegateOutput } from './delegateTool';
import { summariseToolCall } from './interactiveSummary';
import type {
ApprovalInput,
ChannelResolvedValue,
ChatMessage,
ChatMessageRenderPart,
CredentialResolvedValue,
InteractivePayload,
QuestionsResolvedValue,
ToolCall,
} from './types';
const INTERACTIVE_TOOL_NAMES = [
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_LLM_TOOL_NAME,
ASK_QUESTION_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
] as readonly InteractiveToolName[];
type MessageWithInteractives = Pick<ChatMessage, 'interactive' | 'interactives'>;
/** Ambient agent/project scope, needed only to reconstruct a `configure_channel` card from raw tool-call args (see `buildChannelPayloadFromInput`). */
export interface RebuildInteractiveContext {
agentId?: string;
projectId?: string;
}
export function isInteractiveToolName(value: unknown): value is InteractiveToolName {
return typeof value === 'string' && (INTERACTIVE_TOOL_NAMES as readonly string[]).includes(value);
}
@@ -133,9 +147,148 @@ function isDeclinedToolOutput(value: unknown): boolean {
return isRecord(value) && value.declined === true;
}
// ---------------------------------------------------------------------------
// ask_questions / ask_credential / configure_channel — dual-shape parsing
// ---------------------------------------------------------------------------
//
// These three tools transform their resume payload into a distinct tool
// OUTPUT shape rather than echoing it back. `tc.input` is
// similarly dual-shaped: live suspensions overwrite it with the full suspend
// payload (see `useAgentChatStream`'s `tool-call-suspended` handler), but
// persisted history only ever carries the tool's original call args (the
// suspend payload is a transient SSE-only concept, never persisted). The
// `buildXPayloadFromInput` helpers try the rich (suspend) shape first, then
// fall back to synthesizing one from the raw args — enough to render an open
// card even though a few incidental suspend-only fields (e.g. `requestId`,
// never used by the FE beyond correlation) are defaulted.
/** Mirrors `askQuestionsInputSchema` in `ask-questions.tool.ts` — not exported since it's cli-internal. */
const rawAskQuestionsInputSchema = z.object({
questions: z
.array(
z.object({
id: z.string().optional(),
question: z.string(),
type: z.enum(['single', 'multi', 'text']),
options: z.array(z.string()).optional(),
}),
)
.min(1),
introMessage: z.string().optional(),
});
/** Mirrors `configureChannelInputSchema` in `configure-channel.tool.ts` — not exported since it's cli-internal. */
const rawConfigureChannelInputSchema = z.object({
integrationType: z.string(),
});
function buildQuestionsPayloadFromInput(input: unknown): QuestionsSuspendPayload | undefined {
const asSuspend = questionsSuspendPayloadSchema.safeParse(input);
if (asSuspend.success) return asSuspend.data;
const raw = rawAskQuestionsInputSchema.safeParse(input);
if (!raw.success) return undefined;
const questions = raw.data.questions.map((question, index) => ({
...question,
id: question.id ?? `q${index + 1}`,
}));
return {
requestId: '',
message: raw.data.introMessage ?? 'The agent builder has questions',
severity: 'info',
inputType: 'questions',
questions,
...(raw.data.introMessage ? { introMessage: raw.data.introMessage } : {}),
};
}
function buildCredentialPayloadFromInput(input: unknown): CredentialSuspendPayload | undefined {
const asSuspend = credentialSuspendPayloadSchema.safeParse(input);
if (asSuspend.success) return asSuspend.data;
const raw = askCredentialInputSchema.safeParse(input);
if (!raw.success) return undefined;
return {
requestId: '',
message: raw.data.purpose,
severity: 'info',
credentialRequests: [
{
credentialType: raw.data.credentialType,
reason: raw.data.purpose,
existingCredentials: [],
},
],
credentialFlow: { stage: 'generic' },
};
}
function buildChannelPayloadFromInput(
input: unknown,
context?: RebuildInteractiveContext,
): ChannelSuspendPayload | undefined {
const asSuspend = channelSuspendPayloadSchema.safeParse(input);
if (asSuspend.success) return asSuspend.data;
const raw = rawConfigureChannelInputSchema.safeParse(input);
if (!raw.success || !context?.agentId || !context.projectId) return undefined;
// The tool's own args never carry agentId/projectId (they're server-injected
// deps, not LLM-facing input), so this fallback borrows them from the
// ambient route context instead. Safe here specifically because this path
// only runs from `convertDbMessages`, which only ever loads the history of
// the single agent the current chat route is scoped to — the reconstructed
// card can never end up attributed to a different agent/project.
return {
requestId: '',
message: `Set up the ${raw.data.integrationType} channel`,
severity: 'info',
channelConfig: { integrationType: raw.data.integrationType, agentId: context.agentId },
projectId: context.projectId,
};
}
/** Tool output shape for `ask_questions` (see `ask-questions.tool.ts`'s handler return). */
const askQuestionsOutputSchema = z.object({
answered: z.boolean(),
answers: z.array(questionAnswerSchema.extend({ question: z.string().optional() })).optional(),
});
function parseQuestionsResolvedValue(output: unknown): QuestionsResolvedValue | undefined {
const asOutput = askQuestionsOutputSchema.safeParse(output);
if (asOutput.success) return asOutput.data;
const asResume = questionsResumeSchema.safeParse(output);
return asResume.success ? asResume.data : undefined;
}
/** Tool output shape for `ask_credential` / `ask_embedding_credential` (see `AskCredentialToolResult`). */
const askCredentialOutputSchema = z.union([
z.object({ skipped: z.literal(true) }),
z.object({
credentialId: z.string(),
credentialName: z.string(),
credentials: z.record(z.object({ id: z.string(), name: z.string() })).optional(),
}),
]);
function parseCredentialResolvedValue(output: unknown): CredentialResolvedValue | undefined {
const asOutput = askCredentialOutputSchema.safeParse(output);
if (asOutput.success) return asOutput.data;
const asResume = credentialResumeSchema.safeParse(output);
return asResume.success ? asResume.data : undefined;
}
/** Tool output shape for `configure_channel` (see `configure-channel.tool.ts`'s resumed-leg return). */
const configureChannelOutputSchema = z.object({ connected: z.boolean() });
function parseChannelResolvedValue(output: unknown): ChannelResolvedValue | undefined {
const asOutput = configureChannelOutputSchema.safeParse(output);
if (asOutput.success) return asOutput.data;
const asResume = channelResumeSchema.safeParse(output);
return asResume.success ? asResume.data : undefined;
}
function parseAskEmbeddingCredentialOutput(value: unknown) {
const result = askEmbeddingCredentialResumeSchema.safeParse(value);
return result.success ? result.data : null;
return parseCredentialResolvedValue(value);
}
/**
@@ -143,16 +296,24 @@ function parseAskEmbeddingCredentialOutput(value: unknown) {
* reconstruct an `InteractivePayload` for it. The result is:
*
* - **resolved**: when `output` is present `resolvedValue` is parsed from it
* via the matching zod schema. The output IS the user's resume payload (the
* tool handler returns `ctx.resumeData` after a resume), so no separate
* `resumedAt` signal is needed.
* via the matching zod schema. Interactive tools transform the resume
* payload into a distinct output shape, so `resolvedValue` is parsed
* defensively (see the `parse*ResolvedValue` helpers above).
* - **open**: when `output` is absent the card renders as an active
* awaiting-user prompt. Used when a refresh during a suspension restored the
* suspended assistant turn from the open checkpoint.
*
* Returns `undefined` when the tool name isn't interactive or input parsing fails.
*
* `context` supplies the ambient agent/project scope needed only to
* reconstruct an OPEN `configure_channel` card straight from persisted
* history (see `buildChannelPayloadFromInput`) omit it for live SSE calls,
* where `tc.input` already carries the full suspend payload.
*/
export function rebuildInteractiveFromHistory(tc: ToolCall): InteractivePayload | undefined {
export function rebuildInteractiveFromHistory(
tc: ToolCall,
context?: RebuildInteractiveContext,
): InteractivePayload | undefined {
const approvalInput = parseApprovalInput(tc.input);
if (approvalInput) {
return {
@@ -190,51 +351,43 @@ export function rebuildInteractiveFromHistory(tc: ToolCall): InteractivePayload
...(tc.output !== undefined && { resolvedAt: 1 }),
};
if (tc.tool === ASK_CREDENTIAL_TOOL_NAME) {
const input = askCredentialInputSchema.safeParse(tc.input);
if (!input.success) return undefined;
if (tc.tool === ASK_CREDENTIAL_TOOL_NAME || tc.tool === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
const input = buildCredentialPayloadFromInput(tc.input);
if (!input) return undefined;
const resolved =
tc.output !== undefined ? askCredentialResumeSchema.safeParse(tc.output) : null;
tc.output === undefined
? undefined
: tc.tool === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME
? parseAskEmbeddingCredentialOutput(tc.output)
: parseCredentialResolvedValue(tc.output);
return {
...base,
toolName: ASK_CREDENTIAL_TOOL_NAME,
input: input.data,
...(resolved?.success && { resolvedValue: resolved.data }),
};
}
if (tc.tool === ASK_EMBEDDING_CREDENTIAL_TOOL_NAME) {
const input = askCredentialInputSchema.safeParse(tc.input);
if (!input.success) return undefined;
const resolved = tc.output !== undefined ? parseAskEmbeddingCredentialOutput(tc.output) : null;
return {
...base,
toolName: ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
input: input.data,
toolName: tc.tool,
input,
...(resolved && { resolvedValue: resolved }),
};
}
if (tc.tool === ASK_LLM_TOOL_NAME) {
const input = askLlmInputSchema.safeParse(tc.input ?? {});
if (!input.success) return undefined;
const resolved = tc.output !== undefined ? askLlmResumeSchema.safeParse(tc.output) : null;
if (tc.tool === CONFIGURE_CHANNEL_TOOL_NAME) {
const input = buildChannelPayloadFromInput(tc.input, context);
if (!input) return undefined;
const resolved = tc.output !== undefined ? parseChannelResolvedValue(tc.output) : undefined;
return {
...base,
toolName: ASK_LLM_TOOL_NAME,
input: input.data,
...(resolved?.success && { resolvedValue: resolved.data }),
toolName: CONFIGURE_CHANNEL_TOOL_NAME,
input,
...(resolved && { resolvedValue: resolved }),
};
}
const input = askQuestionInputSchema.safeParse(tc.input);
if (!input.success) return undefined;
const resolved = tc.output !== undefined ? askQuestionResumeSchema.safeParse(tc.output) : null;
const input = buildQuestionsPayloadFromInput(tc.input);
if (!input) return undefined;
const resolved = tc.output !== undefined ? parseQuestionsResolvedValue(tc.output) : undefined;
return {
...base,
toolName: ASK_QUESTION_TOOL_NAME,
input: input.data,
...(resolved?.success && { resolvedValue: resolved.data }),
toolName: ASK_QUESTIONS_TOOL_NAME,
input,
...(resolved && { resolvedValue: resolved }),
};
}
@@ -245,7 +398,10 @@ export function rebuildInteractiveFromHistory(tc: ToolCall): InteractivePayload
* `InteractivePayload` so the UI re-renders the card in either its open
* (awaiting user) or resolved (disabled) state.
*/
export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatMessage[] {
export function convertDbMessages(
dbMessages: AgentPersistedMessageDto[],
context?: RebuildInteractiveContext,
): ChatMessage[] {
const result: ChatMessage[] = [];
for (const msg of dbMessages) {
@@ -302,7 +458,7 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
};
toolCalls.push(toolCall);
const rebuilt = rebuildInteractiveFromHistory(toolCall);
const rebuilt = rebuildInteractiveFromHistory(toolCall, context);
if (!rebuilt) continue;
if (rebuilt.resolvedAt === undefined) {
toolCall.state = TOOL_CALL_STATE.SUSPENDED;
@@ -1,17 +1,17 @@
import {
type APPROVAL_TOOL_NAME,
type ASK_CREDENTIAL_TOOL_NAME,
type ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
type ASK_LLM_TOOL_NAME,
type ASK_QUESTION_TOOL_NAME,
type APPROVAL_TOOL_NAME,
type ASK_QUESTIONS_TOOL_NAME,
type CONFIGURE_CHANNEL_TOOL_NAME,
type N8N_CHAT_ACTION_TOOL_NAME,
type AskCredentialInput,
type AskCredentialResume,
type AskEmbeddingCredentialResume,
type AskLlmInput,
type AskLlmResume,
type AskQuestionInput,
type AskQuestionResume,
type ChannelResumeData,
type ChannelSuspendPayload,
type CredentialResumeData,
type CredentialSuspendPayload,
type QuestionAnswer,
type QuestionsResumeData,
type QuestionsSuspendPayload,
} from '@n8n/api-types';
import type { N8nChatInteractionInput, N8nChatResumeValue } from './n8nChatInteraction';
@@ -34,7 +34,7 @@ export interface ToolCall {
/**
* One-line answer label rendered next to the tool name in
* `AgentChatToolSteps`. Set when an interactive tool resolves so the user
* sees what they picked (e.g. "Slack") instead of just "ask_question".
* sees what they picked (e.g. "Slack") instead of just "ask_questions".
*/
displaySummary?: string;
/**
@@ -72,6 +72,34 @@ export interface ApprovalResume {
approved: boolean;
}
/**
* `ask_questions` / `ask_credential` / `configure_channel` each transform
* the FE's resume payload into a distinct tool-output shape rather than
* echoing it back verbatim. `resolvedValue` can therefore be EITHER shape
* depending on where it came from:
*
* - the resume payload itself (optimistic UI update in `useAgentChatStream`'s
* `resume()`, applied before the backend confirms), or
* - the real tool output (from a `tool-result` SSE event or persisted
* history) once the backend has actually run the tool's handler.
*
* Cards interpret `resolvedValue` defensively with type guards instead of
* assuming one shape.
*/
export type QuestionsResolvedValue =
| QuestionsResumeData
| { answered: boolean; answers?: Array<QuestionAnswer & { question?: string }> };
export type CredentialResolvedValue =
| CredentialResumeData
| {
credentialId: string;
credentialName: string;
credentials?: Record<string, { id: string; name: string }>;
};
export type ChannelResolvedValue = ChannelResumeData | { connected: boolean };
/**
* Discriminated union describing the interactive card that a suspended tool call
* renders in the chat. `toolName` is the discriminant.
@@ -83,24 +111,19 @@ export type InteractivePayload =
resolvedValue?: ApprovalResume;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_CREDENTIAL_TOOL_NAME;
input: AskCredentialInput;
resolvedValue?: AskCredentialResume;
toolName: typeof ASK_QUESTIONS_TOOL_NAME;
input: QuestionsSuspendPayload;
resolvedValue?: QuestionsResolvedValue;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_EMBEDDING_CREDENTIAL_TOOL_NAME;
input: AskCredentialInput;
resolvedValue?: AskEmbeddingCredentialResume;
toolName: typeof ASK_CREDENTIAL_TOOL_NAME | typeof ASK_EMBEDDING_CREDENTIAL_TOOL_NAME;
input: CredentialSuspendPayload;
resolvedValue?: CredentialResolvedValue;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_LLM_TOOL_NAME;
input: AskLlmInput;
resolvedValue?: AskLlmResume;
})
| (InteractivePayloadBase & {
toolName: typeof ASK_QUESTION_TOOL_NAME;
input: AskQuestionInput;
resolvedValue?: AskQuestionResume;
toolName: typeof CONFIGURE_CHANNEL_TOOL_NAME;
input: ChannelSuspendPayload;
resolvedValue?: ChannelResolvedValue;
})
| (InteractivePayloadBase & {
toolName: typeof N8N_CHAT_ACTION_TOOL_NAME;
@@ -0,0 +1,239 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
/**
* `ChannelSetupCard` owns the body + orchestration that used to be
* duplicated between `ConfigureChannelCard.vue` (agents chat) and
* `InstanceAiChannelSetup.vue` (instance AI) see those files' own tests
* for how each surface maps the `resolve` event emitted here onto its own
* transport.
*/
const mocks = vi.hoisted(() => {
const slackIntegration = {
type: 'slack',
label: 'Slack',
icon: 'slack',
credentialTypes: ['slackOAuth2Api'],
};
return {
slackIntegration,
ensureLoaded: vi.fn(),
fetchStatus: vi.fn(),
connect: vi.fn(),
getAgent: vi.fn(),
createSlackAgentApp: vi.fn(),
};
});
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (k: string) => k }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('@n8n/permissions', () => ({
getResourcePermissions: () => ({
credential: { create: true, read: true, update: true, delete: true, share: true, move: true },
}),
}));
vi.mock('@/features/agents/composables/useAgentIntegrationsCatalog', () => ({
useAgentIntegrationsCatalog: () => ({
catalog: ref([mocks.slackIntegration]),
ensureLoaded: mocks.ensureLoaded,
}),
}));
vi.mock('@/features/agents/composables/useAgentIntegrationStatus', () => ({
useAgentIntegrationStatus: () => ({
connectedCredentials: ref<Record<string, string>>({}),
integrationSettings: ref({}),
loadingMap: ref<Record<string, boolean>>({}),
errorMessages: ref<Record<string, string>>({}),
errorIsConflict: ref<Record<string, boolean>>({}),
fetchStatus: mocks.fetchStatus,
connect: mocks.connect,
isConnected: () => false,
}),
}));
vi.mock('@/features/agents/composables/useAgentApi', () => ({
getAgent: mocks.getAgent,
createSlackAgentApp: mocks.createSlackAgentApp,
}));
vi.mock('@/features/agents/components/AgentChannelSlackSetup.vue', () => ({
default: {
props: ['modelValue', 'setupMode', 'setupSlackApp'],
emits: ['update:modelValue', 'connect'],
// `setupSlackApp` can reject (e.g. popup blocked) — mirror the real
// component catching that itself, so the test doesn't see an unhandled
// rejection when asserting the resulting no-op.
setup(props: { setupSlackApp?: (appConfigurationToken: string) => Promise<boolean> }) {
async function runSlackAppSetup() {
try {
await props.setupSlackApp?.('app-token');
} catch {
// swallowed, matching the real AgentChannelSlackSetup component
}
}
return { runSlackAppSetup };
},
template: `
<div data-testid="mock-slack-setup" :data-setup-mode="setupMode">
<button
data-testid="mock-slack-connect"
@click="$emit('update:modelValue', 'cred-1'); $emit('connect')"
>Connect</button>
<button
data-testid="mock-slack-connect-twice"
@click="$emit('update:modelValue', 'cred-1'); $emit('connect'); $emit('connect')"
>Connect Twice</button>
<button
data-testid="mock-slack-app-setup"
@click="runSlackAppSetup"
>Install Slack app</button>
</div>
`,
},
}));
import ChannelSetupCard from './ChannelSetupCard.vue';
const defaultProps = {
integrationType: 'slack',
agentId: 'agent-1',
projectId: 'project-1',
};
function mountCard(props: Record<string, unknown> = {}) {
return mount(ChannelSetupCard, {
props: { ...defaultProps, ...props },
global: {
stubs: {
N8nButton: {
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
props: ['disabled'],
},
N8nIcon: { template: '<i />', props: ['icon', 'size', 'color'] },
N8nText: { template: '<span><slot/></span>', props: ['size', 'bold', 'color', 'tag'] },
},
},
});
}
describe('ChannelSetupCard', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia({ stubActions: false }));
mocks.ensureLoaded.mockResolvedValue([mocks.slackIntegration]);
mocks.fetchStatus.mockResolvedValue(undefined);
mocks.connect.mockResolvedValue({ status: 'connected' });
mocks.getAgent.mockResolvedValue({ name: 'Agent', id: 'agent-1' });
mocks.createSlackAgentApp.mockResolvedValue({ installUrl: 'https://slack.com/oauth/install' });
});
it('renders the setup UI for the requested integration type', async () => {
const wrapper = mountCard();
await flushPromises();
expect(wrapper.find('[data-testid="mock-slack-setup"]').attributes('data-setup-mode')).toBe(
'simple',
);
});
it('emits resolve({ approved: true }) after the channel connects', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="mock-slack-connect"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledWith('slack', 'cred-1', undefined);
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
});
it('emits resolve({ approved: false }) when the user skips setup', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="channel-setup-card-skip"]').trigger('click');
expect(wrapper.emitted('resolve')).toEqual([[{ approved: false }]]);
});
it('does not connect twice when setup emits connect twice synchronously', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="mock-slack-connect-twice"]').trigger('click');
await flushPromises();
expect(mocks.connect).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
});
it('keeps the skip button disabled while a connection is in flight', async () => {
let resolveConnect: (value: { status: string }) => void = () => {};
mocks.connect.mockReturnValueOnce(
new Promise((resolve) => {
resolveConnect = resolve;
}),
);
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="mock-slack-connect"]').trigger('click');
await flushPromises();
expect(
wrapper.find('[data-testid="channel-setup-card-skip"]').attributes('disabled'),
).toBeDefined();
await wrapper.find('[data-testid="channel-setup-card-skip"]').trigger('click');
expect(wrapper.emitted('resolve')).toBeUndefined();
resolveConnect({ status: 'connected' });
await flushPromises();
expect(wrapper.emitted('resolve')).toEqual([[{ approved: true }]]);
});
it('does not emit resolve when the Slack app authorization popup is blocked', async () => {
vi.spyOn(window, 'open').mockReturnValueOnce(null);
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="mock-slack-app-setup"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('resolve')).toBeUndefined();
});
it('renders the unsupported-channel placeholder instead of a blank body when the catalog descriptor is missing', async () => {
// Catalog loaded successfully but has no entry for this (known) type —
// e.g. a fetch failure that fell back to an empty/partial list.
const wrapper = mountCard({ integrationType: 'linear' });
await flushPromises();
expect(wrapper.text()).toContain('agents.channels.modal.setupPlaceholder');
});
it('does not call connect or emit resolve when the disabled prop is already true', async () => {
const wrapper = mountCard({ disabled: true });
await flushPromises();
await wrapper.find('[data-testid="mock-slack-connect"]').trigger('click');
await flushPromises();
expect(mocks.connect).not.toHaveBeenCalled();
expect(wrapper.emitted('resolve')).toBeUndefined();
});
});
@@ -0,0 +1,334 @@
<script lang="ts" setup>
/**
* Shared channel-setup body + orchestration for the `configure_channel`
* builder tool. Single-sourced because the agents-chat builder
* (`ConfigureChannelCard.vue`) and the AI assistant
* (`InstanceAiChannelSetup.vue`) render the identical `AgentChannel*Setup`
* flow for the identical suspend payload only how each surface reports the
* outcome differs (agents-chat resumes the tool call directly, instance AI
* goes through its own confirm/resolve transport). This component owns the
* body + composable wiring and emits a single `resolve` event; the two
* surfaces are thin adapters around it that translate `resolve` into their
* own transport call.
*/
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { useI18n } from '@n8n/i18n';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, watch } from 'vue';
import { getAgent } from '@/features/agents/composables/useAgentApi';
import { useAgentChannelSetup } from '@/features/agents/composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '@/features/agents/composables/useAgentIntegrationStatus';
import { useAgentIntegrationsCatalog } from '@/features/agents/composables/useAgentIntegrationsCatalog';
import AgentChannelLinearSetup from '@/features/agents/components/AgentChannelLinearSetup.vue';
import AgentChannelSlackSetup from '@/features/agents/components/AgentChannelSlackSetup.vue';
import AgentChannelTelegramSetup from '@/features/agents/components/AgentChannelTelegramSetup.vue';
import type { AgentResource } from '@/features/agents/types';
const props = defineProps<{
integrationType: string;
agentId: string;
projectId: string;
/**
* External gate an adapter can set once it already considers this card
* resolved through its own transport (e.g. instance AI's
* `resolvedConfirmationIds`), so a stale/duplicate action can't sneak a
* second `resolve` through. Independent of this component's own
* double-submit guard.
*/
disabled?: boolean;
}>();
const emit = defineEmits<{
resolve: [{ approved: boolean }];
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const { catalog, ensureLoaded } = useAgentIntegrationsCatalog();
const {
fetchStatus,
connectedCredentials,
integrationSettings,
loadingMap,
errorMessages,
errorIsConflict,
isConnected: isIntegrationConnected,
connect,
} = useAgentIntegrationStatus(props.projectId, props.agentId);
const submitted = ref(false);
const connectionInFlight = ref(false);
const agent = ref<AgentResource | null>(null);
const currentIntegration = computed<ChatIntegrationDescriptor | null>(() => {
return catalog.value?.find((integration) => integration.type === props.integrationType) ?? null;
});
const {
channelSetupRef,
selectedCredentials,
credentialsLoading,
credentialPermissions,
getChannelCredentialId,
getCredentials,
loadChannelState: loadSharedChannelState,
createCredential,
editCredential,
setupSlackApp: runSlackAppSetup,
} = useAgentChannelSetup({
projectId: () => props.projectId,
agentId: () => props.agentId,
currentIntegration,
connectedCredentials,
fetchStatus,
isIntegrationConnected,
});
const integrationLabel = computed(() => currentIntegration.value?.label ?? props.integrationType);
const connectedDescriptionKeys = {
telegram: 'agents.builder.addTrigger.connectedText.telegram',
linear: 'agents.builder.addTrigger.connectedText.linear',
} as const;
const connectedDescription = computed(() => {
const key =
connectedDescriptionKeys[props.integrationType as keyof typeof connectedDescriptionKeys];
return key ? i18n.baseText(key) : '';
});
const currentChannelCredentialId = computed(() => getChannelCredentialId(props.integrationType));
const currentCredentials = computed(() => getCredentials(props.integrationType));
const isConnected = computed(() => isIntegrationConnected(props.integrationType));
const isLoading = computed(() => loadingMap.value[props.integrationType] ?? false);
const errorMessage = computed(() => errorMessages.value[props.integrationType] ?? '');
const hasUnsupportedIntegration = computed(() => {
if (props.integrationType === 'slack') return false;
if (!['telegram', 'linear'].includes(props.integrationType)) return true;
// Known type, but its catalog descriptor didn't load (e.g. catalog fetch
// failed) the Linear/Telegram branches below need `currentIntegration`,
// so fall back here instead of rendering a blank body.
return !currentIntegration.value;
});
const cardTitle = computed(() =>
i18n.baseText('agents.channels.modal.connectTitle', {
interpolate: { channel: integrationLabel.value },
}),
);
function toIconName(icon: string): IconName {
return icon as IconName;
}
function isBlocked() {
return submitted.value || !!props.disabled;
}
function finish(approved: boolean) {
if (isBlocked()) return;
submitted.value = true;
emit('resolve', { approved });
}
function skipSetup() {
if (connectionInFlight.value) return;
finish(false);
}
async function saveChannelConfig() {
if (isBlocked() || connectionInFlight.value) return;
const credentialId = currentChannelCredentialId.value;
if (!credentialId || channelSetupRef.value?.validationError) return;
connectionInFlight.value = true;
try {
await connect(props.integrationType, credentialId, channelSetupRef.value?.currentSettings);
finish(true);
} catch {
// useAgentIntegrationStatus exposes the connection error to the setup component.
} finally {
connectionInFlight.value = false;
}
}
async function setupSlackApp(appConfigurationToken: string): Promise<boolean> {
if (isBlocked() || connectionInFlight.value) return false;
connectionInFlight.value = true;
try {
return await runSlackAppSetup(appConfigurationToken, () => finish(true));
} finally {
connectionInFlight.value = false;
}
}
async function loadChannelState() {
const integrations = await ensureLoaded(props.projectId).catch(() => catalog.value ?? []);
await loadSharedChannelState(integrations);
if (props.integrationType !== 'slack') {
try {
agent.value = await getAgent(rootStore.restApiContext, props.projectId, props.agentId);
} catch {
agent.value = null;
}
}
}
watch(
() => [props.projectId, props.agentId, props.integrationType] as const,
() => void loadChannelState(),
{ immediate: true },
);
</script>
<template>
<div :class="$style.card">
<header :class="$style.header">
<N8nIcon
v-if="currentIntegration?.icon"
:icon="toIconName(currentIntegration.icon)"
size="medium"
/>
<N8nText :class="$style.title" size="medium" color="text-dark" bold>
{{ cardTitle }}
</N8nText>
</header>
<div :class="$style.bodyWrapper">
<AgentChannelSlackSetup
v-if="integrationType === 'slack'"
ref="channelSetupRef"
v-model="selectedCredentials.slack"
mode="setup"
:connected="isConnected"
:is-published="false"
:setup-slack-app="setupSlackApp"
:project-id="projectId"
:agent-id="agentId"
:integration="currentIntegration ?? undefined"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict.slack"
:force-new-credential="true"
setup-mode="simple"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelLinearSetup
v-else-if="currentIntegration?.type === 'linear'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConnected"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="false"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<AgentChannelTelegramSetup
v-else-if="currentIntegration?.type === 'telegram'"
ref="channelSetupRef"
v-model="selectedCredentials[currentIntegration.type]"
mode="setup"
:integration="currentIntegration"
:credentials="currentCredentials"
:credential-permissions="credentialPermissions"
:credentials-loading="credentialsLoading"
:loading="isLoading"
:connected="isConnected"
:connected-description="connectedDescription"
:error-message="errorMessage"
:error-is-conflict="errorIsConflict[currentIntegration.type]"
:saved-settings="integrationSettings[currentIntegration.type]"
:is-published="false"
:agent-name="agent?.name ?? agentId"
:project-id="projectId"
:agent-id="agentId"
:force-new-credential="true"
@create="createCredential"
@edit="editCredential"
@connect="saveChannelConfig"
/>
<N8nText v-else-if="hasUnsupportedIntegration" size="small" color="text-light">
{{
i18n.baseText('agents.channels.modal.setupPlaceholder', {
interpolate: { channel: integrationLabel },
})
}}
</N8nText>
</div>
<footer :class="$style.footer">
<N8nButton
variant="ghost"
size="medium"
:disabled="connectionInFlight"
data-testid="channel-setup-card-skip"
@click="skipSetup"
>
{{ i18n.baseText('instanceAi.workflowSetup.later') }}
</N8nButton>
</footer>
</div>
</template>
<style lang="scss" module>
.card {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
padding-top: var(--spacing--sm);
/* Waiting-for-input highlight (#33959) ported from InstanceAiChannelSetup
when the card body moved here, so both surfaces get it. */
border: 2px solid var(--color--primary);
border-radius: var(--radius--lg);
background-color: var(--background--surface);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: 0 var(--spacing--sm);
text-transform: capitalize;
}
.title {
flex: 1;
}
.bodyWrapper {
padding: 0 var(--spacing--sm);
}
.footer {
display: flex;
justify-content: flex-end;
padding: 0 var(--spacing--sm) var(--spacing--sm);
}
</style>