mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
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:
@@ -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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|-------|------|----------|-------------|
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+18
-3
@@ -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',
|
||||
|
||||
+58
-25
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
-80
@@ -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'] });
|
||||
});
|
||||
});
|
||||
+229
@@ -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?',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
+115
@@ -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",
|
||||
|
||||
+32
@@ -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');
|
||||
});
|
||||
});
|
||||
+12
-3
@@ -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)];
|
||||
|
||||
+28
-18
@@ -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();
|
||||
});
|
||||
});
|
||||
+102
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+68
-79
@@ -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?'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+30
-25
@@ -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');
|
||||
});
|
||||
|
||||
+40
-25
@@ -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');
|
||||
});
|
||||
|
||||
+1
@@ -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')"
|
||||
|
||||
+9
@@ -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"
|
||||
|
||||
+186
-82
@@ -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;
|
||||
|
||||
-150
@@ -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>
|
||||
-324
@@ -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>
|
||||
+122
@@ -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>
|
||||
+81
@@ -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>
|
||||
+76
-65
@@ -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 {
|
||||
|
||||
+52
-242
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+28
-289
@@ -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>
|
||||
|
||||
+106
-12
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
+50
-31
@@ -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;
|
||||
|
||||
+239
@@ -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>
|
||||
Reference in New Issue
Block a user