feat(core): Cascade builder sub-agent questions into the AI assistant chat (no-changelog) (#34086)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann 2026-07-14 16:22:03 +02:00 committed by GitHub
parent 33723ea285
commit fd13039be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 2791 additions and 6026 deletions

View File

@ -420,6 +420,42 @@ describe('AgentRuntime — execution counters', () => {
expect(counter.incrementTokenCount).toHaveBeenCalledTimes(2);
});
it('exposes the checkpointed suspend payload to a resumed tool handler via ctx.suspendPayload', async () => {
const suspendPayload = { question: 'approve?', marker: 'xyz' };
let observedCtx: InterruptibleToolContext | undefined;
const suspendTool: BuiltTool = {
name: 'approve',
description: 'Requires approval',
inputSchema: z.object({ question: z.string() }),
suspendSchema: z.object({ question: z.string(), marker: z.string() }),
resumeSchema: z.object({ approved: z.boolean() }),
handler: async (_input: unknown, ctx: unknown) => {
const interruptibleCtx = ctx as InterruptibleToolContext;
if (!interruptibleCtx.resumeData) {
return await interruptibleCtx.suspend(suspendPayload);
}
observedCtx = interruptibleCtx;
return { approved: true };
},
};
const { runtime } = createRuntimeWithTools([suspendTool], 1);
generateText.mockResolvedValueOnce(
makeGenerateWithToolCalls([
{ toolCallId: 'tc-1', toolName: 'approve', args: { question: 'continue?' } },
]),
);
const first = await runtime.generate('needs approval');
const { runId, toolCallId } = first.pendingSuspend![0];
generateText.mockResolvedValueOnce(makeGenerateSuccess('approved'));
await runtime.resume('generate', { approved: true }, { runId, toolCallId });
expect(observedCtx?.suspendPayload).toEqual(suspendPayload);
});
it('keeps delegate_subagent output usage per tool call without adding it to generate result usage', async () => {
const delegateTool: BuiltTool = {
name: DELEGATE_SUB_AGENT_TOOL_NAME,

View File

@ -254,4 +254,23 @@ describe('executeTool — context propagation', () => {
expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ executionCounter }));
});
it('passes the checkpointed suspend payload to interruptible tool handlers on resume', async () => {
const handler = vi.fn().mockResolvedValue('ok');
const tool: BuiltTool = {
name: 'interruptible-resumed',
description: 'd',
handler,
suspendSchema: z.object({ requestId: z.string() }),
};
await executeTool({}, tool, { approved: true }, undefined, 'call-1', {
suspendPayload: { requestId: 'r1' },
});
expect(handler).toHaveBeenCalledWith(
{},
expect.objectContaining({ suspendPayload: { requestId: 'r1' } }),
);
});
});

View File

@ -123,6 +123,7 @@ export async function executeTool(
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
executionCounter: executionContext.executionCounter,
suspendPayload: executionContext.suspendPayload,
};
return await builtTool.handler(args, ctx);
}

View File

@ -137,6 +137,8 @@ interface ProcessToolCallParams {
abortSignal?: AbortSignal;
/** Whether this counts as a new tool-call invocation. Default `true`; `false` on resume. */
countToolCall?: boolean;
/** Checkpointed suspend payload of the tool call being resumed. */
suspendPayload?: unknown;
}
function isDeniedApprovalResumeData(value: unknown): boolean {
@ -448,6 +450,7 @@ export class ToolCallExecutor {
executionCounter,
abortSignal,
countToolCall: false,
suspendPayload: resumedEntry.suspended ? resumedEntry.suspendPayload : undefined,
});
if (processResult.outcome === 'suspended') {
@ -730,6 +733,7 @@ export class ToolCallExecutor {
resolvedTelemetry,
executionCounter,
abortSignal,
suspendPayload,
} = params;
return await this.telemetry.withToolSpan(
toolCallId,
@ -743,6 +747,7 @@ export class ToolCallExecutor {
emitEvent: (event) => this.eventBus.emit(event),
abortSignal,
executionCounter,
suspendPayload,
}),
);
}

View File

@ -30,6 +30,11 @@ export interface ToolExecutionContext {
abortSignal?: AbortSignal;
/** Aggregate execution counter for usage telemetry inherited from the current agent run. */
executionCounter?: AgentExecutionCounter;
/**
* Checkpointed suspend payload for a resumed interruptible tool call,
* restored from persistence. Only set when the tool is being resumed.
*/
suspendPayload?: unknown;
}
export interface ToolContext {
@ -74,6 +79,8 @@ export interface InterruptibleToolContext<S = unknown, R = unknown> {
abortSignal?: ToolExecutionContext['abortSignal'];
/** Aggregate execution counter for usage telemetry inherited from the current agent run. */
executionCounter?: ToolExecutionContext['executionCounter'];
/** The payload this tool passed to `suspend()` when it suspended, restored from the checkpoint. Only set when the tool is being resumed. */
suspendPayload?: S;
}
export interface BuiltTool {

View File

@ -1,10 +1,5 @@
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.
*
@ -15,7 +10,6 @@ import {
*/
export const ASK_CREDENTIAL_TOOL_NAME = 'ask_credential' as const;
export const ASK_EMBEDDING_CREDENTIAL_TOOL_NAME = 'ask_embedding_credential' as const;
export { ASK_QUESTIONS_TOOL_NAME, CONFIGURE_CHANNEL_TOOL_NAME };
/**
* Frontend-only discriminator for generic approval cards.
*
@ -24,15 +18,6 @@ export { ASK_QUESTIONS_TOOL_NAME, CONFIGURE_CHANNEL_TOOL_NAME };
*/
export const APPROVAL_TOOL_NAME = 'approval' as const;
export const interactiveToolNameSchema = z.union([
z.literal(ASK_CREDENTIAL_TOOL_NAME),
z.literal(ASK_EMBEDDING_CREDENTIAL_TOOL_NAME),
z.literal(ASK_QUESTIONS_TOOL_NAME),
z.literal(CONFIGURE_CHANNEL_TOOL_NAME),
]);
export type InteractiveToolName = z.infer<typeof interactiveToolNameSchema>;
// ---------------------------------------------------------------------------
// ask_credential
// ---------------------------------------------------------------------------
@ -54,9 +39,9 @@ export type AskCredentialInput = z.infer<typeof askCredentialInputSchema>;
/**
* 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`).
* the shared instance-AI-compatible contract (`agents/agent-interaction.schema.ts`)
* instead of a builder-only shape see that module for the full suspend
* payload (`credentialSuspendPayloadSchema`).
*/
// ---------------------------------------------------------------------------

View File

@ -29,11 +29,11 @@ import type { AgentPersistedMessageContentPart } from './agents';
export interface ToolSuspendedPayload {
toolCallId: string;
/** Run id of the suspended turn; FE echoes this back on `POST /build/resume`. */
/** Run id of the suspended turn; FE echoes this back on `POST /:agentId/chat/:threadId/resume`. */
runId: string;
/** Also the discriminator on the wire (no separate interactionType field). */
toolName: string;
/** Shape determined by toolName via the corresponding Ask*InputSchema. */
/** The tool's suspend payload; shape determined by toolName via the shared interaction-contract suspend schemas (`agents/agent-interaction.schema.ts`). */
input: unknown;
}
@ -95,9 +95,6 @@ export type AgentSseEvent =
}
| { type: 'tool-call-suspended'; payload: ToolSuspendedPayload }
| { type: 'message'; message: AgentSseMessage }
| { type: 'code-delta'; delta: string }
| { type: 'config-updated' }
| { type: 'tool-updated' }
| {
type: 'error';
message: string;

View File

@ -1,7 +1,7 @@
import { credentialResumeSchema, questionsResumeSchema } from '../agent-interaction.schema';
import { AgentBuildResumeDto } from '../dto';
import { AgentChatResumeDto } from '../dto';
describe('AgentBuildResumeDto', () => {
describe('AgentChatResumeDto', () => {
const base = { runId: 'run-1', toolCallId: 'tc-1' };
it('does not strip answers from a questions-card resume', () => {
@ -10,7 +10,7 @@ describe('AgentBuildResumeDto', () => {
answers: [{ questionId: 'q1', selectedOptions: ['a'] }],
};
const result = AgentBuildResumeDto.safeParse({ ...base, resumeData });
const result = AgentChatResumeDto.safeParse({ ...base, resumeData });
expect(result.success).toBe(true);
if (!result.success) return;
@ -28,7 +28,7 @@ describe('AgentBuildResumeDto', () => {
it('still allows a plain credential-card denial to resolve as skipped', () => {
const resumeData = { approved: false };
const result = AgentBuildResumeDto.safeParse({ ...base, resumeData });
const result = AgentChatResumeDto.safeParse({ ...base, resumeData });
expect(result.success).toBe(true);
if (!result.success) return;

View File

@ -25,6 +25,16 @@ export const CONFIGURE_CHANNEL_TOOL_NAME = 'configure_channel' as const;
*/
export const BUILDER_NOT_CONFIGURED_CODE = 'BUILDER_NOT_CONFIGURED' as const;
/**
* Stable code on `BuilderCheckpointUnavailableError`
* (`packages/cli/src/modules/agents/builder/errors.ts`), thrown by
* `AgentsBuilderService.resumeBuild` when the checkpoint being resumed has
* expired or no longer exists. Pinned here so
* `isBuilderCheckpointUnavailableError` in instance AI's `build-agent.tool.ts`
* can detect the state by matching the thrown error's `code`.
*/
export const BUILDER_CHECKPOINT_UNAVAILABLE_CODE = 'BUILDER_CHECKPOINT_UNAVAILABLE' as const;
/**
* The only two agent-builder tools that mutate the agent config. Mirrors
* `BUILDER_TOOLS.WRITE_CONFIG` / `PATCH_CONFIG` in

View File

@ -205,7 +205,7 @@ export class AgentChatMessageDto extends Z.class({
sessionId: z.string().min(1).optional(),
}) {}
export class AgentBuildResumeDto extends Z.class({
export class AgentChatResumeDto extends Z.class({
runId: z.string().min(1),
toolCallId: z.string().min(1),
// Deliberately untyped at this boundary: the possible resume shapes overlap
@ -213,13 +213,7 @@ export class AgentBuildResumeDto extends Z.class({
// 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({
runId: z.string().min(1),
toolCallId: z.string().min(1),
// `.resume(schema)`.
resumeData: z.unknown(),
}) {}

View File

@ -20,10 +20,8 @@ export {
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
APPROVAL_TOOL_NAME,
interactiveToolNameSchema,
askCredentialInputSchema,
cancellationResumeSchema,
type InteractiveToolName,
type AskCredentialInput,
type CancellationResumeData,
} from '../agent-builder-interactive';

View File

@ -226,7 +226,6 @@ export type AgentBuilderAdminSettings = z.infer<typeof agentBuilderAdminSettings
export const agentBuilderAdminSettingsResponseSchema = z.object({
settings: agentBuilderAdminSettingsSchema,
isConfigured: z.boolean(),
});
export type AgentBuilderAdminSettingsResponse = z.infer<
typeof agentBuilderAdminSettingsResponseSchema
@ -235,17 +234,13 @@ export type AgentBuilderAdminSettingsResponse = z.infer<
export const AgentBuilderAdminSettingsUpdateDto = agentBuilderAdminSettingsSchema;
export type AgentBuilderAdminSettingsUpdateRequest = AgentBuilderAdminSettings;
export const agentBuilderStatusResponseSchema = z.object({
isConfigured: z.boolean(),
});
export type AgentBuilderStatusResponse = z.infer<typeof agentBuilderStatusResponseSchema>;
export interface AgentBuilderOpenSuspension {
toolCallId: string;
runId: string;
}
export interface AgentBuilderMessagesResponse {
/** Chat history envelope returned by the agent chat messages endpoints. */
export interface AgentChatMessagesResponse {
messages: AgentPersistedMessageDto[];
openSuspensions: AgentBuilderOpenSuspension[];
}
@ -258,6 +253,3 @@ export const N8N_CHAT_INTEGRATION_TYPE = 'n8n_chat' as const;
/** Fixed tool names for the implicit in-app chat integration (no credential suffixes). */
export const N8N_CHAT_ACTION_TOOL_NAME = 'chat_action' as const;
export const N8N_CHAT_CONTEXT_TOOL_NAME = 'chat_context' as const;
/** Chat history envelope — same contract as {@link AgentBuilderMessagesResponse}. */
export type AgentChatMessagesResponse = AgentBuilderMessagesResponse;

View File

@ -342,8 +342,10 @@ export {
InstanceAiEventsQuery,
InstanceAiCorrectTaskRequest,
InstanceAiEnsureThreadRequest,
instanceAiAgentAttachmentSchema,
instanceAiAttachmentSchema,
instanceAiFileAttachmentSchema,
instanceAiResourceAttachmentSchema,
instanceAiWorkflowAttachmentSchema,
InstanceAiThreadMessagesQuery,
InstanceAiAdminSettingsUpdateRequest,
@ -397,6 +399,7 @@ export type {
McpToolCallRequest,
McpToolCallResult,
InstanceAiEvent,
InstanceAiAgentAttachment,
InstanceAiAttachment,
InstanceAiSendMessageResponse,
InstanceAiToolCallState,
@ -432,6 +435,7 @@ export type {
InstanceAiPermissions,
InstanceAiTargetResource,
InstanceAiFileAttachment,
InstanceAiResourceAttachment,
InstanceAiWorkflowAttachment,
DomainAccessAction,
DomainAccessGrants,

View File

@ -852,10 +852,31 @@ export const instanceAiWorkflowAttachmentSchema = z.object({
});
export type InstanceAiWorkflowAttachment = z.infer<typeof instanceAiWorkflowAttachmentSchema>;
/**
* An agent reference the agents page hands off to a message. Carries no bytes
* the agent resolves it with its tools and the FE shows it as an artifact tab.
*/
export const instanceAiAgentAttachmentSchema = z.object({
type: z.literal('agent'),
id: z.string().min(1).max(64),
name: z.string().max(255).optional(),
/** Project that owns the agent — required so the FE artifact preview can render. */
projectId: z.string().min(1).max(64),
});
export type InstanceAiAgentAttachment = z.infer<typeof instanceAiAgentAttachmentSchema>;
/** A resource reference attachable to a message (as opposed to a binary file). */
export const instanceAiResourceAttachmentSchema = z.discriminatedUnion('type', [
instanceAiWorkflowAttachmentSchema,
instanceAiAgentAttachmentSchema,
]);
export type InstanceAiResourceAttachment = z.infer<typeof instanceAiResourceAttachmentSchema>;
/** Anything attachable to a message: a binary file or a resource reference. */
export const instanceAiAttachmentSchema = z.discriminatedUnion('type', [
instanceAiFileAttachmentSchema,
instanceAiWorkflowAttachmentSchema,
instanceAiAgentAttachmentSchema,
]);
export type InstanceAiAttachment = z.infer<typeof instanceAiAttachmentSchema>;

View File

@ -678,16 +678,14 @@ sandbox) to consult these before planning or building non-trivial workflows.
### `build-agent` *(orchestration tool — requires the `agents` backend module)*
Delegates agent building to the agents-module builder chat
(`AgentsBuilderService`) running as an embedded, **non-interactive** sub-agent:
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_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.
(`AgentsBuilderService`) running as an embedded sub-agent: 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, including its interactive tools (`ask_questions`,
`ask_credential`, `ask_embedding_credential`, `configure_channel`) — the
sub-agent session no longer excludes them. 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 |
|-------|------|----------|-------------|
@ -697,14 +695,23 @@ never appears in the agents-module builder UI.
| `workflowContext` | array | no | `{ id, name, description? }` refs to session-built workflows the builder may attach as tools |
**Returns**: `{ ok: true, builderReply, configUpdated }` on success, or
`{ ok: false, error }`.
`{ ok: false, error, configUpdated? }` on failure. `configUpdated` is optional:
it's included (reporting mutations from passes that already ran) once a
builder turn has actually been dispatched — mid-turn failures and resume
failures that still carry a prior checkpoint ref — but omitted for
precondition failures before any turn starts (agents module not configured,
missing `name`/`agentId`, no project context to bind `agentId`, or a resume
whose suspend payload has no checkpoint ref to carry).
**Relaying open questions:** since the builder cannot ask the user directly,
any decision it needs (missing credential, channel setup, ambiguous model
choice) comes back as text at the end of `builderReply`. The calling assistant
is responsible for surfacing those questions to the user (via its own
question tool/card if available) and sending the answers back through another
`build-agent` call.
**Interactive questions:** when the builder suspends on one of its interactive
tools (batched questions, a credential picker, or channel setup), this tool
cascades the suspension through its own suspend/resume so it renders as a
chat card directly in the assistant conversation — no manual relaying, and the
suspension survives a process restart. On resume, the tool re-derives the
target agent and the builder's open suspension from persistence and verifies
they match the suspension it originally cascaded before routing the answer
back; a stale or superseded suspension fails the call instead of silently
resuming the wrong one.
**Targeting:** the first call must pass `name` (new agent) or `agentId`
(existing agent); the binding is then persisted to thread metadata so

View File

@ -0,0 +1,26 @@
{
"description": "Explicit agent request with chat-shaped Q&A. Gold: {anchor: agent, embedsOther: false} — the deliverable is an n8n Agent artifact, not a workflow with a Chat Trigger + AI Agent node (the historical misroute this case pins).",
"conversation": [
{
"role": "user",
"text": "Build me an agent that answers customer questions from our docs. Don't build anything yet — first walk me through how you'd set this up."
},
{
"role": "user",
"text": [
"[Do not approve any plan, setup card, or build confirmation.",
"If the assistant asks a clarifying question, give a minimal plausible answer.",
"Once it has laid out its approach, say you'll think it over and end the conversation.]"
]
}
],
"complexity": "medium",
"tags": ["intent-resolution", "explicit-artifact", "chat"],
"datasets": ["agents"],
"processExpectations": [
"The agent proposes creating an n8n Agent as the deliverable for the docs Q&A assistant.",
"The proposal does NOT design a workflow with a Chat Trigger and an AI Agent node as the top-level shape.",
"The proposal grounds the agent choice in the explicit agent request and the chat-shaped, open-ended Q&A — not in generic preference."
],
"messageBudget": 4
}

View File

@ -84,29 +84,41 @@ directly onto the matching anchor value.
0. If the user is mid-build on an existing workflow or agent, apply context
continuity (see Signals) before anything else — an incremental request
normally extends the current primitive.
1. If the request is not a build intent — a meta or product question, or a
1. **Explicit artifact requests.** When the user names the deliverable —
"build me an agent/assistant that…", "create a workflow that…" — the
named primitive is a routing instruction, not surface vocabulary.
Classify by it unless the described behavior is unambiguously the other
primitive's shape (e.g. "an agent" whose behavior is a fixed
schedule-fetch-notify pipeline). Even then, never switch silently:
propose the reclassified design and say you are deviating from the named
primitive, grounding the choice in the task's shape. The false-friends
rule applies to task descriptions, not to an explicitly requested
artifact.
2. If the request is not a build intent — a meta or product question, or a
one-off content task with no trigger or reuse — classify **out-of-scope**
and answer or do it directly.
2. Split the request into parts only if it contains multiple independent
3. Split the request into parts only if it contains multiple independent
automations with separate lifecycles (unrelated triggers, audiences, or
cadences). Markers like numbering or "and separately" are a giveaway but
are not required — a single plain sentence can contain two automations.
Do not split a single automation that merely enumerates many tools or
steps. Run steps 3-7 on each part.
3. Test the agent signals. If any one holds, classify **agent-anchored**.
4. Otherwise, test the workflow conditions. If all of them hold, classify
steps. Run steps 4-8 on each part.
4. Test the agent signals. If any one holds, classify **agent-anchored**.
5. Otherwise, test the workflow conditions. If all of them hold, classify
**workflow-anchored**.
5. Decide `embeds_other` in both directions: does an agent step appear inside
6. Decide `embeds_other` in both directions: does an agent step appear inside
this workflow, or does this agent invoke workflows as tools?
6. If the request is under-specified on an anchor-deciding axis (rule-based
7. If the request is under-specified on an anchor-deciding axis (rule-based
vs judgment-based, scope/autonomy, interaction mode), classify
**needs-clarification** and name the missing axis instead of guessing.
7. If both anchors are genuinely defensible, apply the growth tiebreaker:
8. If both anchors are genuinely defensible, apply the growth tiebreaker:
prefer whichever primitive scales with likely complexity growth — usually
agent-anchored when novel situations, longer horizons, or learning are
implied. The tiebreaker applies only to genuine ties: when a bounded
workflow reading fully satisfies the request, prefer it. If it is a real
toss-up, say so and name both readings instead of feigning certainty.
The workflow preference applies to task-shaped requests; it never
overrides an explicitly requested agent artifact (step 1).
## Signals
@ -119,7 +131,9 @@ directly onto the matching anchor value.
whether to intervene.
- Self-improving or skill accretion is first-class: learns from feedback
over time, gets better at the task.
- Chat or session-based interaction.
- Chat or session-based interaction. A workflow with a Chat Trigger is not
a substitute — this signal holds unless the chat merely triggers a fixed
pipeline (see Gotchas).
- Cross-session memory.
**Workflow-anchored** (all must hold):
@ -162,8 +176,10 @@ instead.
**False friends — not signals**:
- Surface vocabulary: "agent", "assistant", "bot", "workflow", "automate" in
the request text carry no weight. Classify the shape, not the words.
- Surface vocabulary: "agent", "assistant", "bot", "workflow", "automate"
in a *task description* carry no weight — classify the shape, not the
words. An explicit artifact request ("build me an agent that…") is not a
false friend; see Decision Step 1.
- Step count and tool count: long linear pipelines and high tool counts are
not agentic. Seven deterministic steps with zero branches is still a
workflow.
@ -204,11 +220,22 @@ instead.
`embeds_other: true`: long-running coordination invoking a workflow tool.
- "Configure an AI agent to send me a nightly digest of new GitHub stars."
-> **workflow-anchored**, `embeds_other: false`: fixed schedule and action
despite the word "agent" — a false friend.
despite the word "agent" — a false friend. When the user instead
explicitly asks to *build an agent* around a fixed pipeline like this,
keep the workflow classification but say so rather than switching
silently (step 1).
- "Spin up a lightweight workflow that talks to shoppers on our storefront
and handles their product questions." -> **agent-anchored**: chat-based
Q&A means the LLM owns turn-by-turn control despite the word "workflow" —
a false friend in the other direction.
- "Build me an agent that answers customer questions from our docs." ->
**agent-anchored**, `embeds_other: false`: explicit agent artifact
request plus chat-shaped open-ended Q&A. The deliverable is an n8n Agent
— not a workflow with a Chat Trigger and an AI Agent node.
- "Give me a chat box where I paste a company name and it runs our
enrichment steps and replies with the result." -> **workflow-anchored**,
`embeds_other: false`: chat is merely the manual trigger for a fixed
graph — the one case where a Chat Trigger workflow is the right build.
- "Post every new Airtable record to a Discord channel, and separately set up
an agent that handles customer refund requests end-to-end." -> two parts,
joined only by topic, not data or trigger: "Airtable-to-Discord posting"
@ -242,7 +269,17 @@ instead.
- Never split a compound request on tool or step enumeration alone — split
only on separate lifecycles.
- Unnecessary agency adds latency, cost, and compounding error risk — do not
reach for an agent when a bounded workflow fully satisfies the request.
reach for an agent when a bounded workflow fully satisfies a task-shaped
request. This is not a license to override an explicit agent request.
- Never satisfy an **agent-anchored** classification with a workflow
containing a Chat Trigger + AI Agent node. Agent-anchored requests
produce an n8n Agent artifact via the agent build path; the AI Agent
*node* exists only for `embeds_other: true` steps inside a genuinely
workflow-anchored pipeline. A Chat Trigger workflow is correct only when
chat is merely the manual trigger for a fixed graph.
- Do not demote an explicitly requested agent to an embedded AI Agent step
inside a workflow — workflow-anchored with `embeds_other: true` is for
agent steps inside a pipeline the user described as a pipeline.
- Do not use an agent when progress cannot be verified: if the path cannot
be scripted and the result cannot be checked, the design is not ready.
- Respect the current build context: an incremental request stays on the
@ -258,7 +295,7 @@ Return a concise classification and reason:
Anchor: workflow-anchored | agent-anchored | needs-clarification | out-of-scope
Embeds other: true | false | n/a
Reason: <one or two sentences citing the deciding signals>
Next step: <build workflow / build workflow with embedded agent step / design agent (workflows as tools where actions should be reusable) / ask clarification / answer directly>
Next step: <build workflow / build workflow with embedded agent step / build n8n Agent artifact (agent build path; workflows as tools where actions should be reusable) / ask clarification / answer directly>
```
For build requests, do not expose this format unless the user asks for

View File

@ -45,7 +45,7 @@ const INTENT_HINT = isAgentFeatureEnabled()
: '';
const AGENT_BUILD_ROUTE = isAgentFeatureEnabled()
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. The builder finishes every turn and lists any open questions at the end of `builderReply` — relay those questions to the user (using your own question card/tool if available), then send the answers back via another `build-agent` call. Actions the agent should invoke as reusable tools are built as workflows via `workflow-builder` first, then attached via `workflowContext`.'
? '\n- **Agent build or edit** (agent-anchored per the intent gate: chat or session interaction, cross-session memory, proactive or long-running operation, learning from feedback) → call `build-agent` right away and let the builder gather requirements — do not run your own requirement-gathering round first. The builder cannot see this conversation: its only knowledge is what you pass in `message`, so include ALL requirements, constraints, and answers already gathered in the first `message` (plus `name` for a new agent or `agentId` for an existing one, and `workflowContext` for workflows built this session). While a build is in progress, forward each user follow-up to `build-agent` near-verbatim and relay its `builderReply` back. When the builder needs user input it asks directly through interactive cards in this chat — do not re-ask those questions yourself; the tool call resumes with the users answer and returns the builders reply. Actions the agent should invoke as reusable tools are built as workflows via `workflow-builder` first, then attached via `workflowContext`.'
: '';
const WORKFLOW_ROUTE_GATE_REF = isAgentFeatureEnabled()

View File

@ -637,6 +637,7 @@ export type {
InstanceAiBuilderDelegate,
BuilderDelegateSession,
BuilderTurnStream,
BuilderOpenSuspension,
SessionWorkflowRef,
} from './types';
export type {

View File

@ -0,0 +1,494 @@
/**
* AGENT-354 build-agent HITL cascade, end to end through REAL SDK mechanics.
*
* `build-agent.tool.test.ts` mocks `InstanceAiBuilderDelegate` and invokes the
* handler directly with hand-built `ctx` objects it never touches the AI
* SDK's own suspend/resume plumbing. That plumbing is exactly where a real P1
* was found: the SDK validates resume data against a tool's `.resume()`
* schema and REPLACES `ctx.resumeData` with the parse result, so a
* badly-chosen schema (e.g. a permissive zod union) silently strips fields
* the handler never sees. Mocking the delegate can't catch that, because the
* mock never runs real validation.
*
* This test drives the actual cascade through two REAL `Agent` instances (the
* orchestrator and the builder sub-agent), each backed by a scripted
* `LanguageModel` so the real `streamText` loop, tool executor, and
* suspend/resume checkpoint machinery all run unmodified no `vi.mock` of
* `ai` or `@n8n/agents`. It exercises three seams unit tests cannot reach:
*
* 1. `ctx.suspendPayload` restoration on resume (the cli's `builderCheckpoint`
* ref, read back from the checkpoint store, not from in-memory state).
* 2. Two-level SDK resume validation: the orchestrator's permissive
* passthrough schema, then the builder's own `questionsResumeSchema`
* the user's answer must survive both without being stripped or replaced.
* 3. A full "suspend drop every in-memory object resume from checkpoint
* storage only" restart, per AGENT-354's testing requirement: phase 2
* below constructs fresh Agent instances, a fresh delegate, and a fresh
* domain context, and resumes purely from the shared checkpoint store and
* thread-metadata record (the only state carried across phases).
*/
import { Agent, Tool } from '@n8n/agents';
import type {
CheckpointStore,
SerializableAgentState,
StreamChunk,
StreamResult,
} from '@n8n/agents';
import {
questionsResumeSchema,
questionsSuspendPayloadSchema,
type QuestionsResumeData,
} from '@n8n/api-types';
import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test';
import { mock } from 'vitest-mock-extended';
import { z } from 'zod';
import type { InstanceAiEventBus } from '../../../event-bus/event-bus.interface';
import type { Logger } from '../../../logger';
import type { PatchableThreadMemory, ThreadRecord } from '../../../storage/thread-patch';
import type {
BuilderOpenSuspension,
BuilderTurnStream,
InstanceAiBuilderDelegate,
InstanceAiContext,
OrchestrationContext,
} from '../../../types';
import { ORCHESTRATION_TOOL_IDS } from '../../tool-ids';
import { createBuildAgentTool } from '../build-agent.tool';
type MockStreamResult = Awaited<ReturnType<MockLanguageModelV3['doStream']>>;
type MockStreamPart = MockStreamResult['stream'] extends ReadableStream<infer P> ? P : never;
const USAGE: Extract<MockStreamPart, { type: 'finish' }>['usage'] = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
const BUILDER_THREAD_ID = 'ia-builder:thread-1:agent-1';
function makeToolCallTurn(
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
): MockStreamResult {
const parts: MockStreamPart[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'tool-call', toolCallId, toolName, input: JSON.stringify(args) },
{ type: 'finish', finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, usage: USAGE },
];
return { stream: convertArrayToReadableStream(parts) };
}
function makeTextTurn(text: string): MockStreamResult {
const parts: MockStreamPart[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'text-start', id: 'txt-1' },
{ type: 'text-delta', id: 'txt-1', delta: text },
{ type: 'text-end', id: 'txt-1' },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: USAGE },
];
return { stream: convertArrayToReadableStream(parts) };
}
function createScriptedModel(turns: MockStreamResult[]): MockLanguageModelV3 {
let next = 0;
return new MockLanguageModelV3({
provider: 'mock',
modelId: 'scripted',
doStream: async () => await Promise.resolve(turns[next++]),
});
}
/**
* Shared durable checkpoint storage the only agent-run state that survives
* the "restart" between phase 1 and phase 2. Mirrors the in-repo precedent in
* `@n8n/agents`' `state-restore-after-suspension.test.ts`, plus a `listStates`
* helper so the test delegate can scan for open suspensions by thread id, the
* same way the cli adapter's `findOpenCheckpointForThread` does.
*/
class InMemoryCheckpointStore implements CheckpointStore {
private store = new Map<string, SerializableAgentState>();
async save(key: string, state: SerializableAgentState): Promise<void> {
await Promise.resolve(this.store.set(key, structuredClone(state)));
}
async load(key: string): Promise<SerializableAgentState | undefined> {
const state = this.store.get(key);
return await Promise.resolve(state ? structuredClone(state) : undefined);
}
async delete(key: string): Promise<void> {
await Promise.resolve(this.store.delete(key));
}
listStates(): Array<[string, SerializableAgentState]> {
return [...this.store.entries()];
}
get size(): number {
return this.store.size;
}
}
/** Read all chunks from a stream into an array. */
async function collectStreamChunks(stream: ReadableStream<StreamChunk>): Promise<StreamChunk[]> {
const chunks: StreamChunk[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
}
function chunksOfType<T extends StreamChunk['type']>(
chunks: StreamChunk[],
type: T,
): Array<StreamChunk & { type: T }> {
return chunks.filter((c) => c.type === type) as Array<StreamChunk & { type: T }>;
}
/** Adapts a real SDK `StreamResult` into the `BuilderTurnStream` shape the
* delegate contract expects mirrors `toBuilderTurnStream` in
* `packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts`. */
function toTurnStream(result: StreamResult): BuilderTurnStream {
let resolveText!: (text: string) => void;
const text = new Promise<string>((resolve) => {
resolveText = resolve;
});
let acc = '';
async function* pump(): AsyncGenerator<StreamChunk> {
const reader = result.stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value.type === 'text-delta') acc += value.delta;
yield value;
}
} finally {
resolveText(acc);
}
}
return { fullStream: pump(), text };
}
/**
* Build the agent-builder sub-agent: `write_config` (a plain config-mutation
* tool its name drives `configUpdated` via `CONFIG_MUTATION_TOOL_NAMES`)
* and `ask_questions` (an interruptible tool using the real shared contract
* from `@n8n/api-types`, mirroring the cli's own `ask_questions` tool). On
* resume, `onResume` records the exact `ctx.resumeData` the SDK handed back
* after validating it against `questionsResumeSchema` the central
* assertion of this test is that nothing was stripped along the way.
*/
function createBuilderAgent(
checkpointStore: CheckpointStore,
model: MockLanguageModelV3,
onResume: (data: QuestionsResumeData) => void,
): Agent {
const writeConfigTool = new Tool('write_config')
.description('Persist the agent configuration')
.input(z.object({}))
.output(z.object({ ok: z.boolean() }))
.handler(async () => await Promise.resolve({ ok: true }));
const askQuestionsTool = new Tool('ask_questions')
.description('Ask the user clarifying questions; suspends until answered')
.input(z.object({}))
.suspend(questionsSuspendPayloadSchema)
.resume(questionsResumeSchema)
.handler(async (_input, ctx) => {
if (ctx.resumeData === undefined || ctx.resumeData === null) {
return await ctx.suspend({
requestId: 'builder-req-1',
message: 'The agent builder has questions',
severity: 'info',
inputType: 'questions',
questions: [
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['slack', 'email'] },
],
});
}
onResume(ctx.resumeData);
return { answered: true };
});
return new Agent('agent-builder')
.model(model)
.instructions('You are the agent builder sub-agent. Use your tools to build the agent.')
.tool(writeConfigTool)
.tool(askQuestionsTool)
.checkpoint(checkpointStore);
}
/**
* Instance-AI builder delegate, backed by the real SDK. Mirrors the thin
* mapping in `InstanceAiBuilderDelegateAdapterService`: one builder Agent
* constructed per call, resuming purely from the shared checkpoint store.
*/
function createBuilderDelegate(
store: InMemoryCheckpointStore,
model: MockLanguageModelV3,
onResume: (data: QuestionsResumeData) => void,
): InstanceAiBuilderDelegate {
return {
createAgent: async (_name: string) =>
await Promise.resolve({ agentId: 'agent-1', projectId: 'proj-1' }),
streamBuild: async (_agentId, message, session) => {
const builder = createBuilderAgent(store, model, onResume);
const result = await builder.stream(message, {
persistence: { threadId: session.threadId, resourceId: 'user-1' },
});
return toTurnStream(result);
},
resumeBuild: async (_agentId, resume, _session) => {
const builder = createBuilderAgent(store, model, onResume);
const result = await builder.resume('stream', resume.resumeData, {
runId: resume.runId,
toolCallId: resume.toolCallId,
});
return toTurnStream(result);
},
findOpenSuspensions: async (_agentId, session) => {
const found: BuilderOpenSuspension[] = [];
for (const [, state] of store.listStates()) {
if (state.status !== 'suspended') continue;
if (state.persistence?.threadId !== session.threadId) continue;
for (const call of Object.values(state.pendingToolCalls)) {
if (call.suspended) found.push({ runId: call.runId, toolCallId: call.toolCallId });
}
}
return await Promise.resolve(found);
},
cancelOpenSuspension: async (_agentId, runId) => {
await store.delete(runId);
},
};
}
/** Thread-metadata stub over a shared record stands in for real thread
* persistence so `saveAgentBuilderTarget`/`resolveAgentBuilderTarget`
* (`agent-target-binding.ts`) can round-trip the bound agent id across the
* simulated restart. Implements the native `getThread`/`saveThread` pair
* that `patchThread` (`storage/thread-patch.ts`) falls back to. */
function createThreadMemoryStub(records: Map<string, ThreadRecord>): PatchableThreadMemory {
return {
getThread: async (threadId: string) => await Promise.resolve(records.get(threadId) ?? null),
saveThread: async (thread: ThreadRecord) => {
records.set(thread.id, thread);
return await Promise.resolve(thread);
},
};
}
function createEventBusStub(): InstanceAiEventBus {
return {
publish: () => {},
subscribe: () => () => {},
getEventsAfter: () => [],
getEventsForRun: () => [],
getEventsForRuns: () => [],
getNextEventId: async () => await Promise.resolve(1),
};
}
function createLoggerStub(): Logger {
return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };
}
/** Builds a fresh `OrchestrationContext` + `InstanceAiContext` pair for one
* phase mirrors `makeContext()` in `build-agent.tool.test.ts`, but with
* real values for the fields the cascade actually keys persistence on
* (`threadId`, `domainContext.threadId`, `domainContext.threadMemory`). */
function createOrchestrationContext(params: {
threadRecords: Map<string, ThreadRecord>;
delegate: InstanceAiBuilderDelegate;
agentBuilderTarget: InstanceAiContext['agentBuilderTarget'];
}): OrchestrationContext {
const domainContext = mock<InstanceAiContext>();
domainContext.projectId = 'proj-1';
domainContext.builderDelegate = params.delegate;
domainContext.threadId = 'thread-1';
domainContext.threadMemory = createThreadMemoryStub(params.threadRecords);
domainContext.agentBuilderTarget = params.agentBuilderTarget;
domainContext.logger = createLoggerStub();
const context = mock<OrchestrationContext>();
context.domainContext = domainContext;
context.threadId = 'thread-1';
context.runId = 'orch-run-1';
context.orchestratorAgentId = 'root';
context.abortSignal = new AbortController().signal;
context.eventBus = createEventBusStub();
context.logger = createLoggerStub();
context.modelId = 'anthropic/test-model';
return context;
}
describe('build-agent cascade restart (real SDK)', () => {
it('suspends on a builder question, drops all in-memory state, and resumes purely from checkpoint storage with the answer intact', async () => {
const store = new InMemoryCheckpointStore();
const threadRecords = new Map<string, ThreadRecord>();
threadRecords.set('thread-1', {
id: 'thread-1',
resourceId: 'user-1',
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
});
// ── Phase 1: build and suspend ──────────────────────────────────────
const phase1Delegate = createBuilderDelegate(
store,
createScriptedModel([
makeToolCallTurn('b-tc-1', 'write_config', {}),
makeToolCallTurn('b-tc-2', 'ask_questions', {}),
]),
() => {
throw new Error('builder should not resume during phase 1');
},
);
const phase1Context = createOrchestrationContext({
threadRecords,
delegate: phase1Delegate,
agentBuilderTarget: undefined,
});
const orchModel1 = createScriptedModel([
makeToolCallTurn(ORCHESTRATION_TOOL_IDS.BUILD_AGENT, ORCHESTRATION_TOOL_IDS.BUILD_AGENT, {
message: 'Build a support agent',
name: 'Support Agent',
}),
]);
const orchestrator1 = new Agent('ia-orchestrator')
.model(orchModel1)
.instructions('Test orchestrator')
.tool(createBuildAgentTool(phase1Context))
.checkpoint(store);
const firstRun = await orchestrator1.stream('Build a support agent');
const phase1Chunks = await collectStreamChunks(firstRun.stream);
const suspendedChunks = chunksOfType(phase1Chunks, 'tool-call-suspended');
expect(suspendedChunks).toHaveLength(1);
const suspended = suspendedChunks[0];
expect(suspended.toolName).toBe(ORCHESTRATION_TOOL_IDS.BUILD_AGENT);
const orchestratorRunId = suspended.runId;
const orchestratorToolCallId = suspended.toolCallId;
expect(orchestratorRunId).toBeTruthy();
expect(orchestratorToolCallId).toBeTruthy();
// Exactly one suspended builder checkpoint, keyed to the builder's
// instance-AI-scoped thread — read its real runId/toolCallId back from
// the store rather than assuming the model's tool-call ids survived.
const suspendedBuilderEntries = store
.listStates()
.filter(
([, state]) =>
state.status === 'suspended' && state.persistence?.threadId === BUILDER_THREAD_ID,
);
expect(suspendedBuilderEntries).toHaveLength(1);
const [, builderState] = suspendedBuilderEntries[0];
const builderPendingCalls = Object.values(builderState.pendingToolCalls).filter(
(call) => call.suspended,
);
expect(builderPendingCalls).toHaveLength(1);
const builderRunId = builderPendingCalls[0].suspended
? builderPendingCalls[0].runId
: undefined;
const builderToolCallId = builderPendingCalls[0].toolCallId;
const suspendPayload = suspended.suspendPayload as Record<string, unknown>;
expect(suspendPayload).toMatchObject({
inputType: 'questions',
severity: 'info',
questions: [
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['slack', 'email'] },
],
builderCheckpoint: {
runId: builderRunId,
toolCallId: builderToolCallId,
configUpdated: true,
},
});
expect(typeof suspendPayload.requestId).toBe('string');
expect(suspendPayload.requestId).not.toBe('builder-req-1');
// The store now holds both the orchestrator's and the builder's own
// suspended checkpoints.
expect(store.size).toBe(2);
// The agent-builder target was persisted to thread metadata (name-path bind).
const persistedThread = threadRecords.get('thread-1');
expect(persistedThread?.metadata?.instanceAiAgentBuilderTarget).toMatchObject({
agentId: 'agent-1',
projectId: 'proj-1',
});
// ── Phase 2: drop everything, resume purely from checkpoint storage ─
let observedBuilderResumeData: QuestionsResumeData | undefined;
const phase2Delegate = createBuilderDelegate(
store,
createScriptedModel([makeTextTurn('Configured Slack as the channel.')]),
(data) => {
observedBuilderResumeData = data;
},
);
const phase2Context = createOrchestrationContext({
threadRecords,
delegate: phase2Delegate,
agentBuilderTarget: undefined,
});
const orchModel2 = createScriptedModel([makeTextTurn('Your agent is ready.')]);
const orchestrator2 = new Agent('ia-orchestrator')
.model(orchModel2)
.instructions('Test orchestrator')
.tool(createBuildAgentTool(phase2Context))
.checkpoint(store);
const resumeData = {
approved: true,
answers: [{ questionId: 'q1', selectedOptions: ['slack'] }],
};
const resumedRun = await orchestrator2.resume('stream', resumeData, {
runId: orchestratorRunId,
toolCallId: orchestratorToolCallId,
});
const phase2Chunks = await collectStreamChunks(resumedRun.stream);
// The user's answer survived BOTH SDK resume-validation layers intact:
// the orchestrator's passthrough resume schema, the delegate pass-through,
// and the builder's own questionsResumeSchema validation.
expect(observedBuilderResumeData).toEqual(resumeData);
const toolResultChunks = chunksOfType(phase2Chunks, 'tool-result').filter(
(c) => c.toolName === ORCHESTRATION_TOOL_IDS.BUILD_AGENT,
);
expect(toolResultChunks).toHaveLength(1);
expect(toolResultChunks[0].output).toMatchObject({
ok: true,
configUpdated: true,
});
const output = toolResultChunks[0].output as { builderReply?: string };
expect(output.builderReply).toContain('Configured Slack');
const finishChunks = chunksOfType(phase2Chunks, 'finish');
expect(finishChunks.length).toBeGreaterThan(0);
expect(finishChunks.at(-1)?.finishReason).toBe('stop');
// The resume settled both checkpoints — no suspended state remains.
const remainingSuspended = store
.listStates()
.filter(([, state]) => state.status === 'suspended');
expect(remainingSuspended).toHaveLength(0);
});
});

View File

@ -1,5 +1,8 @@
import type { InstanceAiEvent } from '@n8n/api-types';
import { BUILDER_CHECKPOINT_UNAVAILABLE_CODE, type InstanceAiEvent } from '@n8n/api-types';
import { UserError } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { z } from 'zod';
import { executeTool } from '../../../__tests__/tool-test-utils';
import type { InstanceAiEventBus } from '../../../event-bus/event-bus.interface';
@ -17,8 +20,9 @@ vi.mock('../agent-target-binding', async () => {
const actual = await vi.importActual<typeof AgentTargetBindingModule>('../agent-target-binding');
return {
...actual,
resolveAgentBuilderTarget: async (ctx: InstanceAiContext) =>
await Promise.resolve(ctx.agentBuilderTarget),
resolveAgentBuilderTarget: vi.fn(
async (ctx: InstanceAiContext) => await Promise.resolve(ctx.agentBuilderTarget),
),
saveAgentBuilderTarget: vi.fn(),
};
});
@ -59,15 +63,20 @@ function throwingStream(error: Error): BuilderTurnStream {
};
}
/** A stream that yields a `tool-call-suspended` chunk — should be unreachable in practice. */
function suspendingStream(): BuilderTurnStream {
/** A stream that yields a `tool-call-suspended` chunk for one of the builder's interactive tools. */
function suspendingStream(
toolName: string,
suspendPayload: Record<string, unknown>,
options: { runId?: string; toolCallId?: string } = {},
): BuilderTurnStream {
return fakeStream(
[
{
type: 'tool-call-suspended',
toolCallId: 'call-1',
toolName: 'ask_questions',
suspendPayload: { message: 'unexpected question', severity: 'info' },
runId: options.runId ?? 'builder-run-1',
toolCallId: options.toolCallId ?? 'builder-call-1',
toolName,
suspendPayload,
},
],
'',
@ -82,6 +91,45 @@ function toolResultChunk(toolCallId: string, output: unknown = {}) {
return { type: 'tool-result', toolCallId, output };
}
function askQuestionsSuspendPayload() {
return {
requestId: 'builder-req-1',
message: 'The agent builder has questions',
severity: 'info' as const,
inputType: 'questions' as const,
questions: [
{
id: 'q1',
question: 'Which channel?',
type: 'single' as const,
options: ['slack', 'email'],
},
],
};
}
function askCredentialSuspendPayload() {
return {
requestId: 'builder-req-2',
message: 'Connect Slack',
severity: 'info' as const,
credentialRequests: [
{ credentialType: 'slackApi', reason: 'To send messages', existingCredentials: [] },
],
credentialFlow: { stage: 'generic' as const },
};
}
function configureChannelSuspendPayload() {
return {
requestId: 'builder-req-3',
message: 'Set up the chat channel',
severity: 'info' as const,
channelConfig: { integrationType: 'slack', agentId: 'agent-1' },
projectId: 'proj-1',
};
}
function makeContext(overrides: { delegate?: InstanceAiBuilderDelegate } = {}): {
context: OrchestrationContext;
delegate: InstanceAiBuilderDelegate;
@ -132,6 +180,16 @@ async function runTool(context: OrchestrationContext, input: Record<string, unkn
return await executeTool<BuildAgentOutput>(tool, input);
}
/** Like `runTool`, but with an explicit interruptible ctx for suspend/resume tests. */
async function runToolWithCtx(
context: OrchestrationContext,
input: Record<string, unknown>,
ctx: Record<string, unknown>,
) {
const tool = createBuildAgentTool(context);
return await executeTool<BuildAgentOutput>(tool, input, ctx);
}
describe('build-agent tool', () => {
beforeEach(() => {
vi.mocked(saveAgentBuilderTarget).mockClear();
@ -215,18 +273,24 @@ describe('build-agent tool', () => {
expect(delegate.streamBuild).not.toHaveBeenCalled();
});
it('maps a thrown builder-not-configured error to a friendly message and publishes agent-completed', async () => {
it('maps a builder-not-configured error thrown mid-stream (during first-call streaming) to a friendly message and publishes agent-completed', async () => {
// The real delegate's `streamBuild`/`resumeBuild` are async generators: the call
// itself never rejects — errors from their bodies only surface once the returned
// stream is consumed. A call-time rejection (as this test used to simulate) cannot
// happen in production; see build-agent.tool.ts's `runBuilderConsumeLoop` catch.
const { context, delegate, publishedEvents } = makeContext();
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
vi.mocked(delegate.streamBuild).mockRejectedValue(
Object.assign(new Error('not configured'), { code: 'BUILDER_NOT_CONFIGURED' }),
vi.mocked(delegate.streamBuild).mockResolvedValue(
throwingStream(
Object.assign(new Error('not configured'), { code: 'BUILDER_NOT_CONFIGURED' }),
),
);
const friendlyMessage =
'The agent builder model is not configured. Set it up in the agents module settings.';
const result = await runTool(context, { message: 'Build it', name: 'New Agent' });
expect(result).toEqual({ ok: false, error: friendlyMessage });
expect(result).toEqual({ ok: false, error: friendlyMessage, configUpdated: false });
expect(publishedEvents.map((event) => event.type)).toEqual([
'agent-spawned',
'agent-completed',
@ -360,26 +424,6 @@ describe('build-agent tool', () => {
});
});
it('fails defensively when the builder run suspends unexpectedly (interactive tools are excluded)', async () => {
const { context, delegate, publishedEvents } = makeContext();
vi.mocked(delegate.createAgent).mockResolvedValue({ agentId: 'agent-1', projectId: 'proj-1' });
vi.mocked(delegate.streamBuild).mockResolvedValue(suspendingStream());
const result = await runTool(context, { message: 'Build it', name: 'New Agent' });
expect(result).toEqual({
ok: false,
error:
'The agent builder run suspended unexpectedly; interactive tools are not available in this chat.',
});
const completed = publishedEvents.at(-1);
expect(completed && 'payload' in completed ? completed.payload : undefined).toMatchObject({
role: 'agent-builder',
error:
'The agent builder run suspended unexpectedly; interactive tools are not available in this chat.',
});
});
describe('configUpdated', () => {
it.each(['write_config', 'patch_config'])(
'is true when the work summary has a succeeded %s call',
@ -491,5 +535,439 @@ describe('build-agent tool', () => {
expect(delegate.createAgent).toHaveBeenCalledWith('Fresh Agent');
});
it('persists the deferred agentId-path bind when the first turn suspends', async () => {
const { context, delegate } = makeContext();
const suspend: Mock = vi.fn().mockResolvedValue(undefined);
vi.mocked(delegate.streamBuild).mockResolvedValue(
suspendingStream('ask_questions', askQuestionsSuspendPayload()),
);
await runToolWithCtx(
context,
{ message: 'Add a tool', agentId: 'agent-existing' },
{ suspend },
);
expect(saveAgentBuilderTarget).toHaveBeenCalledWith(context.domainContext, {
agentId: 'agent-existing',
projectId: 'proj-1',
});
});
});
describe('interactive suspension cascade', () => {
it.each([
['ask_questions', askQuestionsSuspendPayload],
['ask_credential', askCredentialSuspendPayload],
['configure_channel', configureChannelSuspendPayload],
] as const)(
'cascades a %s suspension into ctx.suspend, passing the shared-contract payload through with a re-minted requestId and builderCheckpoint ref',
async (toolName, buildPayload) => {
const { context, delegate } = makeContext();
vi.mocked(delegate.createAgent).mockResolvedValue({
agentId: 'agent-1',
projectId: 'proj-1',
});
vi.mocked(delegate.streamBuild).mockResolvedValue(
suspendingStream(toolName, buildPayload()),
);
const suspend: Mock = vi.fn().mockResolvedValue(undefined);
await runToolWithCtx(context, { message: 'Build it', name: 'New Agent' }, { suspend });
expect(suspend).toHaveBeenCalledTimes(1);
const payload = suspend.mock.calls[0][0] as Record<string, unknown>;
const { requestId: originalRequestId, ...basePayload } = buildPayload();
expect(payload).toMatchObject(basePayload);
expect(payload).toMatchObject({
builderCheckpoint: {
runId: 'builder-run-1',
toolCallId: 'builder-call-1',
configUpdated: false,
},
});
expect(typeof payload.requestId).toBe('string');
expect(payload.requestId).not.toBe(originalRequestId);
},
);
it('carries configUpdated: true in the builderCheckpoint when a write_config succeeded before the suspension', async () => {
const { context, delegate } = makeContext();
vi.mocked(delegate.createAgent).mockResolvedValue({
agentId: 'agent-1',
projectId: 'proj-1',
});
vi.mocked(delegate.streamBuild).mockResolvedValue(
fakeStream(
[
toolCallChunk('call-1', 'write_config'),
toolResultChunk('call-1'),
{
type: 'tool-call-suspended',
runId: 'builder-run-1',
toolCallId: 'builder-call-1',
toolName: 'ask_questions',
suspendPayload: askQuestionsSuspendPayload(),
},
],
'',
),
);
const suspend: Mock = vi.fn().mockResolvedValue(undefined);
await runToolWithCtx(context, { message: 'Build it', name: 'New Agent' }, { suspend });
const payload = suspend.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toMatchObject({ builderCheckpoint: { configUpdated: true } });
});
it('fails the turn and cancels the builder checkpoint when the suspend payload does not match the shared contract', async () => {
const { context, delegate, publishedEvents } = makeContext();
vi.mocked(delegate.createAgent).mockResolvedValue({
agentId: 'agent-1',
projectId: 'proj-1',
});
vi.mocked(delegate.streamBuild).mockResolvedValue(
suspendingStream('ask_questions', { foo: 'bar' }),
);
const suspend: Mock = vi.fn();
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ suspend },
);
expect(suspend).not.toHaveBeenCalled();
expect(result.ok).toBe(false);
expect(result.error).toContain('could not be shown');
expect(result.configUpdated).toBe(false);
expect(delegate.cancelOpenSuspension).toHaveBeenCalledWith('agent-1', 'builder-run-1');
const last = publishedEvents.at(-1);
expect(last).toMatchObject({ type: 'agent-completed' });
});
});
it('resume schema passes non-questions confirm shapes through without stripping (SDK validates resume data against it and replaces the data with the parse result)', () => {
const { context } = makeContext();
const built = createBuildAgentTool(context);
const parsed: unknown = (built.resumeSchema as z.ZodTypeAny).parse({
credentials: { slack: 'cred-1' },
});
expect(parsed).toEqual({ credentials: { slack: 'cred-1' } });
});
describe('resume', () => {
class FakeBuilderCheckpointUnavailableError extends UserError {
readonly code = BUILDER_CHECKPOINT_UNAVAILABLE_CODE;
}
function suspendPayloadWithCheckpoint(
overrides: Partial<{ runId: string; toolCallId: string; configUpdated: boolean }> = {},
) {
return {
...askQuestionsSuspendPayload(),
requestId: 'orch-req-1',
builderCheckpoint: {
runId: 'builder-run-1',
toolCallId: 'builder-call-1',
configUpdated: false,
...overrides,
},
};
}
it('resumes the builder via delegate.resumeBuild with resumeData passed through unchanged when the identity check passes', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(fakeStream([], 'Using Slack.'));
const resumeData = {
approved: true,
answers: [{ questionId: 'q1', selectedOptions: ['slack'] }],
};
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData, suspendPayload: suspendPayloadWithCheckpoint() },
);
expect(delegate.resumeBuild).toHaveBeenCalledWith(
'agent-1',
{ runId: 'builder-run-1', toolCallId: 'builder-call-1', resumeData },
{ threadId: 'ia-builder:thread-1:agent-1', modelConfig: context.modelId },
);
expect(result).toEqual({ ok: true, builderReply: 'Using Slack.', configUpdated: false });
});
it('resumes when the persisted ref matches one of several open suspensions', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
{ runId: 'builder-run-2', toolCallId: 'builder-call-2' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(fakeStream([], 'Using Slack.'));
await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({
runId: 'builder-run-1',
toolCallId: 'builder-call-1',
}),
},
);
expect(delegate.resumeBuild).toHaveBeenCalledWith(
'agent-1',
{ runId: 'builder-run-1', toolCallId: 'builder-call-1', resumeData: { approved: true } },
{ threadId: 'ia-builder:thread-1:agent-1', modelConfig: context.modelId },
);
});
it('fails loudly without resuming when the open suspension does not match the persisted builderCheckpoint ref', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'other-run', toolCallId: 'builder-call-1' },
]);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: suspendPayloadWithCheckpoint() },
);
expect(result.ok).toBe(false);
expect(result.error).toContain('does not match');
expect(delegate.resumeBuild).not.toHaveBeenCalled();
});
it('fails when no builder suspension is open on resume', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([]);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: suspendPayloadWithCheckpoint() },
);
expect(result.ok).toBe(false);
expect(result.error).toContain('no longer open');
expect(delegate.resumeBuild).not.toHaveBeenCalled();
});
it('carries configUpdated forward when no builder suspension is open on resume', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([]);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({ configUpdated: true }),
},
);
expect(result).toEqual({
ok: false,
error: 'The builder question this answer belongs to is no longer open.',
configUpdated: true,
});
});
it('fails when the persisted suspend payload lacks the builderCheckpoint ref', async () => {
const { context, delegate } = makeContext();
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: undefined },
);
expect(result.ok).toBe(false);
expect(delegate.resumeBuild).not.toHaveBeenCalled();
});
it('carries configUpdated forward when the resumed pass re-suspends', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(
suspendingStream('ask_credential', askCredentialSuspendPayload(), {
runId: 'builder-run-2',
toolCallId: 'builder-call-2',
}),
);
const suspend: Mock = vi.fn().mockResolvedValue(undefined);
await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({ configUpdated: true }),
suspend,
},
);
const payload = suspend.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toMatchObject({ builderCheckpoint: { configUpdated: true } });
});
it('ORs carried configUpdated with the resumed pass when finishing', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(fakeStream([], 'Done.'));
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({ configUpdated: true }),
},
);
expect(result.configUpdated).toBe(true);
});
it('reports carried configUpdated when the resumed pass errors', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(
fakeStream([{ type: 'error', error: 'boom' }], ''),
);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({ configUpdated: true }),
},
);
expect(result.ok).toBe(false);
expect(result.configUpdated).toBe(true);
});
it('republishes agent-spawned on resume', async () => {
const { context, delegate, publishedEvents } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(fakeStream([], 'Done.'));
await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: suspendPayloadWithCheckpoint() },
);
expect(publishedEvents[0]).toMatchObject({
type: 'agent-spawned',
agentId: 'agent-builder:agent-1',
});
});
it('maps a builder-not-configured error thrown mid-stream (during resume streaming) to a friendly message', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(
throwingStream(
Object.assign(new Error('not configured'), { code: 'BUILDER_NOT_CONFIGURED' }),
),
);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: suspendPayloadWithCheckpoint() },
);
expect(result).toEqual({
ok: false,
error:
'The agent builder model is not configured. Set it up in the agents module settings.',
configUpdated: false,
});
});
it.each([false, true])(
'friendly-maps a checkpoint-unavailable error thrown mid-stream on resume (carried configUpdated: %s)',
async (carriedConfigUpdated) => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(
throwingStream(
new FakeBuilderCheckpointUnavailableError(
'The builder question this answer belongs to has expired and can no longer be resumed.',
),
),
);
const result = await runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{
resumeData: { approved: true },
suspendPayload: suspendPayloadWithCheckpoint({ configUpdated: carriedConfigUpdated }),
},
);
expect(result).toEqual({
ok: false,
error:
'The builder question this answer belongs to has expired and can no longer be resumed.',
configUpdated: carriedConfigUpdated,
});
},
);
it('still rethrows an unrelated error thrown mid-stream during resume', async () => {
const { context, delegate } = makeContext();
context.domainContext!.agentBuilderTarget = { agentId: 'agent-1', projectId: 'proj-1' };
vi.mocked(delegate.findOpenSuspensions).mockResolvedValue([
{ runId: 'builder-run-1', toolCallId: 'builder-call-1' },
]);
vi.mocked(delegate.resumeBuild).mockResolvedValue(
throwingStream(new Error('boom mid-resume')),
);
await expect(
runToolWithCtx(
context,
{ message: 'Build it', name: 'New Agent' },
{ resumeData: { approved: true }, suspendPayload: suspendPayloadWithCheckpoint() },
),
).rejects.toThrow('boom mid-resume');
});
});
});

View File

@ -3,22 +3,37 @@
* (`AgentsBuilderService`) as an embedded sub-agent, one conversational turn
* per invocation.
*
* This is the non-interactive contract: the delegate session excludes every
* 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.
* This is the interactive contract: the delegate session includes the
* builder's full standard toolset, so it may suspend on `ask_questions`,
* `ask_credential`, `ask_embedding_credential`, or `configure_channel`. When
* it does, this tool cascades the suspension through its own `ctx.suspend()`
* using payloads derived from the shared interaction contract in
* `@n8n/api-types` so the question renders as a card in the calling
* assistant's chat and the orchestrator's own checkpoint survives a process
* restart. On resume, the target agent and the builder's open suspension are
* both re-derived from persistence (no in-memory state carried across the
* suspend boundary) and checked for identity against the `builderCheckpoint`
* ref persisted in the cascaded payload before the answer is routed back.
* If the user abandons a cascaded question (cancel, steering message, or
* confirmation timeout), the builder-side checkpoint is not cleaned up
* eagerly it expires via the agents module's checkpoint TTL pruning.
*
* The builder session is keyed to an instance-AI-scoped thread id
* (`ia-builder:<threadId>:<agentId>`) so nothing appears in the agents-module
* builder UI it is a private sub-agent conversation.
*/
import type { InterruptibleToolContext } from '@n8n/agents';
import { Tool } from '@n8n/agents';
import { BUILDER_NOT_CONFIGURED_CODE, CONFIG_MUTATION_TOOL_NAMES } from '@n8n/api-types';
import {
BUILDER_CHECKPOINT_UNAVAILABLE_CODE,
BUILDER_NOT_CONFIGURED_CODE,
CONFIG_MUTATION_TOOL_NAMES,
channelSuspendPayloadSchema,
credentialSuspendPayloadSchema,
questionsSuspendPayloadSchema,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import {
@ -31,13 +46,31 @@ import {
type ConsumeStreamCascadingResult,
} from '../../stream/consume-with-hitl';
import type { WorkSummary } from '../../stream/work-summary-accumulator';
import type { OrchestrationContext, SessionWorkflowRef } from '../../types';
import type {
BuilderTurnStream,
InstanceAiBuilderDelegate,
InstanceAiContext,
OrchestrationContext,
SessionWorkflowRef,
} from '../../types';
import { ORCHESTRATION_TOOL_IDS } from '../tool-ids';
function isBuilderNotConfiguredError(error: unknown): boolean {
return isRecord(error) && error.code === BUILDER_NOT_CONFIGURED_CODE;
}
/** `AgentsBuilderService.resumeBuild` throws `BuilderCheckpointUnavailableError`
* (stable `code`, shared via `@n8n/api-types`) when the checkpoint being
* resumed has expired or no longer exists. */
function isBuilderCheckpointUnavailableError(error: unknown): boolean {
return isRecord(error) && error.code === BUILDER_CHECKPOINT_UNAVAILABLE_CODE;
}
/** Either friendly-mappable failure this tool recognizes mid-stream. */
function isFriendlyMappableBuilderError(error: unknown): boolean {
return isBuilderNotConfiguredError(error) || isBuilderCheckpointUnavailableError(error);
}
function didUpdateConfig(workSummary: WorkSummary): boolean {
const mutationToolNames = new Set<string>(CONFIG_MUTATION_TOOL_NAMES);
return workSummary.toolCalls.some(
@ -63,6 +96,16 @@ function buildOutboundMessage(message: string, workflowContext?: SessionWorkflow
return `${message}\n\n${formatWorkflowContextEnvelope(workflowContext)}`;
}
/** Builder sessions are keyed per assistant thread + target agent; the resume
* leg must reconstruct this byte-identically after a restart. */
function builderSessionFor(context: OrchestrationContext, agentId: string) {
return { threadId: `ia-builder:${context.threadId}:${agentId}`, modelConfig: context.modelId };
}
function builderAgentIdFor(agentId: string): string {
return `agent-builder:${agentId}`;
}
const buildAgentInputSchema = z.object({
message: z
.string()
@ -89,11 +132,54 @@ const buildAgentOutputSchema = z.object({
type BuildAgentOutput = z.infer<typeof buildAgentOutputSchema>;
/** Durable reference to the builder's own suspended checkpoint, carried inside the
* cascaded suspend payload (persisted in the orchestrator's checkpoint) so the resume
* leg can verify it resumes the same suspension it cascaded. */
const builderCheckpointRefSchema = z.object({
runId: z.string(),
toolCallId: z.string(),
/** Whether any builder pass before this suspension already mutated the agent config. */
configUpdated: z.boolean(),
});
/** Envelope derived from the shared interaction contract (agent-interaction.schema.ts):
* only payloads the assistant FE can render may cascade. */
const builderSuspendPayloadSchema = z.union([
questionsSuspendPayloadSchema,
credentialSuspendPayloadSchema,
channelSuspendPayloadSchema,
]);
const buildAgentSuspendSchema = z.union([
questionsSuspendPayloadSchema.extend({ builderCheckpoint: builderCheckpointRefSchema }),
credentialSuspendPayloadSchema.extend({ builderCheckpoint: builderCheckpointRefSchema }),
channelSuspendPayloadSchema.extend({ builderCheckpoint: builderCheckpointRefSchema }),
]);
/**
* Resume data is NOT semantically validated at this level it passes through
* byte-for-byte to the builder's suspended interactive tool, which validates
* it against its own shared-contract resume schema (`agent-interaction.schema.ts`).
* A zod union of the shared resume schemas would be wrong here: its first
* member (`questionsResumeSchema`) is all-optional, so it matches any object
* and the SDK's resume validation would strip every non-questions field
* (e.g. a `credentials` map) before the handler sees it.
*/
const buildAgentResumeSchema = z.object({}).passthrough();
type BuildAgentSuspendPayload = z.infer<typeof buildAgentSuspendSchema>;
type BuildAgentResumeData = z.infer<typeof buildAgentResumeSchema>;
type BuildAgentToolContext = InterruptibleToolContext<
BuildAgentSuspendPayload,
BuildAgentResumeData
>;
/**
* Publish the `agent-spawned` event announcing the builder sub-agent to the FE.
* Called once per invocation there is no resume leg to republish from in
* this non-interactive version, so the FE's agent-spawned handler only ever
* sees this once per builder target per run.
* Published on the first call that constructs the builder session, and
* republished (idempotently) on resume the FE may have lost the builder
* node across a page reload or process restart, so the resume leg re-sends it
* defensively.
*/
function publishAgentSpawned(
context: OrchestrationContext,
@ -147,10 +233,11 @@ async function finishTurn(
context: OrchestrationContext,
builderAgentId: string,
result: Extract<ConsumeStreamCascadingResult, { status: 'completed' | 'cancelled' | 'errored' }>,
carriedConfigUpdated: boolean,
): Promise<BuildAgentOutput> {
if (result.status === 'completed') {
const text = await result.text;
const configUpdated = didUpdateConfig(result.workSummary);
const configUpdated = carriedConfigUpdated || didUpdateConfig(result.workSummary);
context.eventBus.publish(context.threadId, {
type: 'agent-completed',
runId: context.runId,
@ -161,13 +248,189 @@ async function finishTurn(
}
const error = `The agent builder run ${result.status}.`;
const configUpdated = carriedConfigUpdated || didUpdateConfig(result.workSummary);
context.eventBus.publish(context.threadId, {
type: 'agent-completed',
runId: context.runId,
agentId: builderAgentId,
payload: { role: 'agent-builder', result: '', error },
});
return { ok: false, error };
return { ok: false, error, configUpdated };
}
/**
* Consume a builder turn stream to completion or suspension, and either
* finish the tool call or cascade the suspension through `ctx.suspend()`.
* Shared by the first-call and resume legs of the handler.
*/
async function runBuilderConsumeLoop(params: {
context: OrchestrationContext;
delegate: InstanceAiBuilderDelegate;
ctx: BuildAgentToolContext;
target: AgentBuilderTarget;
builderAgentId: string;
turn: BuilderTurnStream;
/** configUpdated already accumulated by passes before this one (false on the first leg; carried from the suspend payload on resume). */
carriedConfigUpdated: boolean;
/** Runs once the stream settles (any status) — used to persist a deferred agentId-path bind. */
onSettled?: () => Promise<void>;
}): Promise<BuildAgentOutput> {
const { context, delegate, ctx, target, builderAgentId, turn, carriedConfigUpdated, onSettled } =
params;
let result: ConsumeStreamCascadingResult;
try {
result = await consumeStreamCascading({
agent: undefined,
stream: turn,
runId: context.runId,
agentId: builderAgentId,
eventBus: context.eventBus,
logger: context.logger,
threadId: context.threadId,
abortSignal: context.abortSignal,
});
} catch (error) {
// `buildAgent`/`resumeBuild` on the delegate are async generators: calling
// them never throws, so errors from their bodies (builder-not-configured,
// an expired/missing checkpoint) only surface here, during consumption —
// not from the `delegate.streamBuild`/`resumeBuild` call sites.
const message = publishAgentBuilderFailure(context, builderAgentId, error);
if (isFriendlyMappableBuilderError(error)) {
return { ok: false, error: message, configUpdated: carriedConfigUpdated };
}
throw error;
}
// Reaching a settled stream result (any status, including suspended) means
// the builder agent was constructed — scope check and existence check both
// passed — so a deferred agentId-path bind is now safe to persist.
await onSettled?.();
if (result.status !== 'suspended') {
return await finishTurn(context, builderAgentId, result, carriedConfigUpdated);
}
const configUpdatedSoFar = carriedConfigUpdated || didUpdateConfig(result.workSummary);
const builderRunId = result.suspension.runId;
const parsedSuspendPayload = builderRunId
? builderSuspendPayloadSchema.safeParse(result.suspension.suspendPayload)
: undefined;
if (!builderRunId || !parsedSuspendPayload?.success) {
if (builderRunId) {
try {
await delegate.cancelOpenSuspension(target.agentId, builderRunId);
} catch (error) {
context.logger.warn('Failed to cancel orphaned builder checkpoint', {
builderRunId,
error: error instanceof Error ? error.message : String(error),
});
}
}
const message =
"The agent builder's confirmation request could not be shown in this chat; the build turn was cancelled.";
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
return { ok: false, error: message, configUpdated: configUpdatedSoFar };
}
// The builder-level requestId must not leak up: the FE confirms against the
// orchestrator's own suspension, so a fresh one is minted here.
return await ctx.suspend({
...parsedSuspendPayload.data,
requestId: nanoid(),
builderCheckpoint: {
runId: builderRunId,
toolCallId: result.suspension.toolCallId,
configUpdated: configUpdatedSoFar,
},
});
}
/**
* Resume leg: re-derive the target agent and the builder's open suspension
* from persistence, verify they match the `builderCheckpoint` ref carried in
* the cascaded suspend payload, then resume the builder with the user's
* answer passed through unchanged.
*/
async function handleResume(
context: OrchestrationContext,
domainContext: InstanceAiContext,
delegate: InstanceAiBuilderDelegate,
ctx: BuildAgentToolContext,
): Promise<BuildAgentOutput> {
const refParse = z
.object({ builderCheckpoint: builderCheckpointRefSchema })
.safeParse(ctx.suspendPayload);
if (!refParse.success) {
return {
ok: false,
error:
'The suspended build state is missing its builder checkpoint reference; the answer cannot be routed. Start a new build-agent call.',
};
}
const ref = refParse.data.builderCheckpoint;
const target = await resolveAgentBuilderTarget(domainContext);
if (!target) {
return {
ok: false,
error: 'No agent build in progress for this conversation.',
configUpdated: ref.configUpdated,
};
}
const session = builderSessionFor(context, target.agentId);
const openSuspensions = await delegate.findOpenSuspensions(target.agentId, session);
if (openSuspensions.length === 0) {
return {
ok: false,
error: 'The builder question this answer belongs to is no longer open.',
configUpdated: ref.configUpdated,
};
}
const matches = openSuspensions.some(
(open) => open.runId === ref.runId && open.toolCallId === ref.toolCallId,
);
if (!matches) {
return {
ok: false,
error:
"The answer does not match the builder's open question (stale or superseded suspension). Ask the user again with a fresh build-agent call.",
configUpdated: ref.configUpdated,
};
}
const builderAgentId = builderAgentIdFor(target.agentId);
let turn: BuilderTurnStream;
try {
turn = await delegate.resumeBuild(
target.agentId,
{ runId: ref.runId, toolCallId: ref.toolCallId, resumeData: ctx.resumeData },
session,
);
} catch (error) {
// Only genuinely call-time-reachable errors land here (e.g. the scope
// check in the delegate adapter) — see the comment in
// `runBuilderConsumeLoop`'s catch for why builder-not-configured/expired-
// checkpoint errors can't surface at this call site.
publishAgentBuilderFailure(context, builderAgentId, error);
throw error;
}
publishAgentSpawned(context, builderAgentId, target);
return await runBuilderConsumeLoop({
context,
delegate,
ctx,
target,
builderAgentId,
turn,
carriedConfigUpdated: ref.configUpdated,
});
}
export function createBuildAgentTool(context: OrchestrationContext) {
@ -175,18 +438,27 @@ export function createBuildAgentTool(context: OrchestrationContext) {
.description(
'Delegate agent building to the agents-module builder, running as a sub-agent. ' +
'Pass `name` to start a new agent or `agentId` to edit an existing one on the first ' +
'call; subsequent calls keep editing the same agent. Returns the builders reply and ' +
'whether it updated the agent config.',
'call; subsequent calls keep editing the same agent. When the builder needs user ' +
'input (a choice, a credential, or a chat channel), it surfaces automatically as an ' +
'interactive card in this chat — do not relay those questions yourself; this tool ' +
'call resumes with the users answer and returns the builders reply. Returns the ' +
'builders reply and whether it updated the agent config.',
)
.input(buildAgentInputSchema)
.output(buildAgentOutputSchema)
.handler(async (input: z.infer<typeof buildAgentInputSchema>) => {
.suspend(buildAgentSuspendSchema)
.resume(buildAgentResumeSchema)
.handler(async (input: z.infer<typeof buildAgentInputSchema>, ctx: BuildAgentToolContext) => {
const domainContext = context.domainContext;
const delegate = domainContext?.builderDelegate;
if (!domainContext || !delegate) {
return { ok: false, error: 'Agent building is not available on this instance.' };
}
if (ctx.resumeData !== undefined && ctx.resumeData !== null) {
return await handleResume(context, domainContext, delegate, ctx);
}
let target = await resolveAgentBuilderTarget(domainContext);
// Deferred for the agentId path: binding before the builder run settles
// would let a hallucinated/forbidden/missing agentId permanently poison
@ -216,62 +488,41 @@ export function createBuildAgentTool(context: OrchestrationContext) {
};
}
}
const boundTarget: AgentBuilderTarget = target;
const session = {
threadId: `ia-builder:${context.threadId}:${target.agentId}`,
modelConfig: context.modelId,
};
const session = builderSessionFor(context, boundTarget.agentId);
const outboundMessage = buildOutboundMessage(input.message, input.workflowContext);
const builderAgentId = `agent-builder:${target.agentId}`;
const builderAgentId = builderAgentIdFor(boundTarget.agentId);
publishAgentSpawned(context, builderAgentId, target);
publishAgentSpawned(context, builderAgentId, boundTarget);
let turn;
let turn: BuilderTurnStream;
try {
turn = await delegate.streamBuild(target.agentId, outboundMessage, session);
} catch (error) {
const message = publishAgentBuilderFailure(context, builderAgentId, error);
if (isBuilderNotConfiguredError(error)) {
return { ok: false, error: message };
}
throw error;
}
let result: ConsumeStreamCascadingResult;
try {
result = await consumeStreamCascading({
agent: undefined,
stream: turn,
runId: context.runId,
agentId: builderAgentId,
eventBus: context.eventBus,
logger: context.logger,
threadId: context.threadId,
abortSignal: context.abortSignal,
});
turn = await delegate.streamBuild(boundTarget.agentId, outboundMessage, session);
} catch (error) {
// Only genuinely call-time-reachable errors land here (e.g. the scope
// check in the delegate adapter) — see the comment in
// `runBuilderConsumeLoop`'s catch for why builder-not-configured/expired-
// checkpoint errors can't surface at this call site.
publishAgentBuilderFailure(context, builderAgentId, error);
throw error;
}
// Reaching a settled stream result (any status, including the
// defensive `suspended` case below) means the builder agent was
// constructed — scope check and existence check both passed — so the
// deferred agentId-path bind is now safe to persist.
if (bindAfterTurn) {
domainContext.agentBuilderTarget = target;
await saveAgentBuilderTarget(domainContext, target);
}
if (result.status !== 'suspended') return await finishTurn(context, builderAgentId, result);
// Unreachable in practice — interactive tools are excluded from the
// builder's session, so it cannot suspend. Handled defensively rather
// than throwing out of the tool handler.
const message =
'The agent builder run suspended unexpectedly; interactive tools are not available in this chat.';
publishAgentBuilderFailure(context, builderAgentId, new Error(message));
return { ok: false, error: message };
return await runBuilderConsumeLoop({
context,
delegate,
ctx,
target: boundTarget,
builderAgentId,
turn,
carriedConfigUpdated: false,
onSettled: bindAfterTurn
? async () => {
domainContext.agentBuilderTarget = boundTarget;
await saveAgentBuilderTarget(domainContext, boundTarget);
}
: undefined,
});
})
.build();
}

View File

@ -860,11 +860,18 @@ export interface BuilderTurnStream {
text: Promise<string>;
}
/** Reference to a suspended builder tool call awaiting user input. */
export interface BuilderOpenSuspension {
runId: string;
toolCallId: string;
}
/**
* Narrow delegate wrapping the agents-module builder for sub-agent use.
* Provided by the host (cli) only when the agents module is active. This
* version excludes interactive builder tools at the session level, so every
* `streamBuild` call completes, errors, or is cancelled never suspends.
* Provided by the host (cli) only when the agents module is active. Runs the
* builder's full interactive toolset `streamBuild`/`resumeBuild` may
* suspend, which the caller cascades through its own suspend/resume so the
* builder's questions survive a process restart.
*/
export interface InstanceAiBuilderDelegate {
createAgent(name: string): Promise<{ agentId: string; projectId: string }>;
@ -873,6 +880,18 @@ export interface InstanceAiBuilderDelegate {
message: string,
session: BuilderDelegateSession,
): Promise<BuilderTurnStream>;
resumeBuild(
agentId: string,
resume: { runId: string; toolCallId: string; resumeData: unknown },
session: BuilderDelegateSession,
): Promise<BuilderTurnStream>;
/** All suspended tool calls on the builder's open checkpoint for this session thread ([] when none). */
findOpenSuspensions(
agentId: string,
session: BuilderDelegateSession,
): Promise<BuilderOpenSuspension[]>;
/** Expire the builder checkpoint for `runId` so a failed cascade leaves no orphaned open suspension. */
cancelOpenSuspension(agentId: string, runId: string): Promise<void>;
}
// ── Local gateway status ─────────────────────────────────────────────────────

View File

@ -103,6 +103,18 @@ describe('parseSuspension', () => {
expect(parseSuspension(chunk)).toBeNull();
});
it('surfaces runId from the chunk when present', () => {
const chunk = {
type: 'tool-call-suspended',
runId: 'run-1',
toolCallId: 'tc-1',
toolName: 'ask_questions',
suspendPayload: { requestId: 'req-1' },
};
expect(parseSuspension(chunk)?.runId).toBe('run-1');
});
it('handles missing payload gracefully', () => {
const chunk = { type: 'tool-call-suspended' };
expect(parseSuspension(chunk)).toBeNull();

View File

@ -10,6 +10,8 @@ export interface SuspensionInfo {
toolCallId: string;
requestId: string;
toolName?: string;
/** Run id of the (sub-)agent run that suspended, from the `tool-call-suspended` chunk. */
runId?: string;
/** The raw suspend payload as passed to `ctx.suspend()` by the inner tool. */
suspendPayload: Record<string, unknown>;
}
@ -26,9 +28,10 @@ export function parseSuspension(chunk: unknown): SuspensionInfo | null {
? suspPayload.requestId
: tcId;
const toolName = typeof sp.toolName === 'string' ? sp.toolName : undefined;
const runId = typeof sp.runId === 'string' ? sp.runId : undefined;
if (!reqId || !tcId) return null;
return { toolCallId: tcId, requestId: reqId, toolName, suspendPayload: suspPayload };
return { toolCallId: tcId, requestId: reqId, toolName, runId, suspendPayload: suspPayload };
}
export interface Resumable {

View File

@ -1,20 +0,0 @@
import { AgentBuilderController } from '../agent-builder.controller';
import {
expectProjectScopedAgentRoutes,
getRoutesByHandlerName,
} from './test-utils/controller-route-metadata';
describe('AgentBuilderController route access scopes', () => {
expectProjectScopedAgentRoutes(AgentBuilderController);
const routes = getRoutesByHandlerName(AgentBuilderController);
it.each([
['getBuilderMessages', 'agent:read'],
['clearBuilderMessages', 'agent:update'],
['build', 'agent:update'],
['buildResume', 'agent:update'],
])('%s uses %s', (handlerName, scope) => {
expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope);
});
});

View File

@ -154,7 +154,12 @@ describe('AgentExecutionOrchestratorService', () => {
const runtime = makeRuntime([
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: 'Choose one' },
{ type: 'tool-call-suspended', toolCallId: 'tc-1', toolName: 'ask_question', runId: 'run-1' },
{
type: 'tool-call-suspended',
toolCallId: 'tc-1',
toolName: 'ask_questions',
runId: 'run-1',
},
]);
const chunks = await collect(
@ -457,7 +462,7 @@ describe('AgentExecutionOrchestratorService', () => {
{
type: 'tool-call-suspended',
toolCallId: 'tc-2',
toolName: 'ask_question',
toolName: 'ask_questions',
runId: 'run-2',
},
]);

View File

@ -1,4 +1,4 @@
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
import type { CredentialProvider, SerializableAgentState, StreamChunk } from '@n8n/agents';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
@ -11,7 +11,6 @@ import type { Agent } from '../entities/agent.entity';
import {
INSTANCE_AI_BUILDER_ADDENDUM,
InstanceAiBuilderDelegateAdapterService,
NON_INTERACTIVE_EXCLUDED_TOOL_NAMES,
} from '../instance-ai-builder-delegate.adapter';
function setup() {
@ -65,7 +64,7 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
await expect(turn.text).resolves.toBe('Hello world');
});
it('builds the sub-agent session from the delegate session: thread id, model config, addendum, and excluded tools', async () => {
it('builds the sub-agent session from the delegate session: thread id, model config, and addendum', async () => {
const { delegate, agentsBuilderService, user, credentialProvider } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
agentsBuilderService.buildAgent.mockReturnValue(asAsyncGenerator<StreamChunk>([]));
@ -85,7 +84,6 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
threadId: 'ia-builder:t:agent-1',
modelConfig: 'anthropic/claude-sonnet-host-resolved',
instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM,
excludeTools: NON_INTERACTIVE_EXCLUDED_TOOL_NAMES,
},
);
});
@ -101,6 +99,170 @@ describe('InstanceAiBuilderDelegateAdapterService', () => {
});
});
describe('resumeBuild', () => {
it('forwards to agentsBuilderService.resumeBuild and accumulates text-delta chunks', async () => {
const { delegate, agentsBuilderService, user, credentialProvider } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
const chunks: StreamChunk[] = [
{ type: 'text-delta', id: '1', delta: 'Using ' },
{ type: 'text-delta', id: '2', delta: 'Slack.' },
];
agentsBuilderService.resumeBuild.mockReturnValue(asAsyncGenerator(chunks));
const turn = await delegate.resumeBuild(
'agent-1',
{ runId: 'run-1', toolCallId: 'call-1', resumeData: { approved: true } },
{ threadId: 'ia-builder:t:agent-1', modelConfig: 'anthropic/claude-sonnet-host-resolved' },
);
const seen: unknown[] = [];
for await (const chunk of turn.fullStream) seen.push(chunk);
expect(seen).toHaveLength(2);
await expect(turn.text).resolves.toBe('Using Slack.');
expect(agentsBuilderService.resumeBuild).toHaveBeenCalledWith(
'agent-1',
'project-1',
'run-1',
'call-1',
{ approved: true },
credentialProvider,
user,
{
threadId: 'ia-builder:t:agent-1',
modelConfig: 'anthropic/claude-sonnet-host-resolved',
instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM,
},
);
});
it('rejects when the user lacks agent:update scope', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
await expect(
delegate.resumeBuild(
'agent-1',
{ runId: 'run-1', toolCallId: 'call-1', resumeData: {} },
{ threadId: 'ia-builder:t:agent-1' },
),
).rejects.toThrow(ForbiddenError);
expect(agentsBuilderService.resumeBuild).not.toHaveBeenCalled();
});
});
describe('findOpenSuspensions', () => {
function checkpointWith(
pendingToolCalls: SerializableAgentState['pendingToolCalls'],
): SerializableAgentState {
return {
status: 'suspended',
messageList: { messages: [], historyIds: [], inputIds: [], responseIds: [] },
pendingToolCalls,
};
}
it('returns all suspended pending tool calls mapped to runId/toolCallId', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(
checkpointWith({
'call-1': {
toolCallId: 'call-1',
toolName: 'ask_questions',
input: {},
suspended: true,
suspendPayload: { message: 'first' },
resumeSchema: {},
runId: 'run-1',
},
'call-2': {
toolCallId: 'call-2',
toolName: 'ask_credential',
input: {},
suspended: true,
suspendPayload: { message: 'second' },
resumeSchema: {},
runId: 'run-2',
},
}),
);
const result = await delegate.findOpenSuspensions('agent-1', {
threadId: 'ia-builder:t:agent-1',
});
expect(agentsBuilderService.findOpenCheckpointForThread).toHaveBeenCalledWith(
'agent-1',
'ia-builder:t:agent-1',
);
expect(result).toEqual([
{ runId: 'run-1', toolCallId: 'call-1' },
{ runId: 'run-2', toolCallId: 'call-2' },
]);
});
it('returns [] when findOpenCheckpointForThread resolves null', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(null);
const result = await delegate.findOpenSuspensions('agent-1', {
threadId: 'ia-builder:t:agent-1',
});
expect(result).toEqual([]);
});
it('returns [] when the checkpoint has no suspended calls', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
agentsBuilderService.findOpenCheckpointForThread.mockResolvedValue(
checkpointWith({
'call-1': { toolCallId: 'call-1', toolName: 'read_config', input: {}, suspended: false },
}),
);
const result = await delegate.findOpenSuspensions('agent-1', {
threadId: 'ia-builder:t:agent-1',
});
expect(result).toEqual([]);
});
it('rejects when the user lacks agent:update scope', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
await expect(
delegate.findOpenSuspensions('agent-1', { threadId: 'ia-builder:t:agent-1' }),
).rejects.toThrow(ForbiddenError);
expect(agentsBuilderService.findOpenCheckpointForThread).not.toHaveBeenCalled();
});
});
describe('cancelOpenSuspension', () => {
it('calls agentsBuilderService.cancelCheckpoint(agentId, runId)', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(true);
await delegate.cancelOpenSuspension('agent-1', 'run-1');
expect(agentsBuilderService.cancelCheckpoint).toHaveBeenCalledWith('agent-1', 'run-1');
});
it('rejects when the user lacks agent:update scope', async () => {
const { delegate, agentsBuilderService } = setup();
vi.spyOn(checkAccess, 'userHasScopes').mockResolvedValue(false);
await expect(delegate.cancelOpenSuspension('agent-1', 'run-1')).rejects.toThrow(
ForbiddenError,
);
expect(agentsBuilderService.cancelCheckpoint).not.toHaveBeenCalled();
});
});
describe('createAgent', () => {
it('enforces agent:create scope and delegates to AgentsService', async () => {
const { delegate, agentsService } = setup();

View File

@ -1,209 +0,0 @@
import {
AgentBuildResumeDto,
type AgentBuilderMessagesResponse,
AgentChatMessageDto,
type AgentSseEvent,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Delete, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { AgentsCredentialProvider } from './adapters/agents-credential-provider';
import { messagesToDto } from './agent-message-mapper';
import {
type FlushableResponse,
initSseStream,
pumpChunks,
type ToolEventCallbacks,
} from './agent-sse-stream';
import { AgentsService } from './agents.service';
import { AgentsBuilderService } from './builder/agents-builder.service';
import { BUILDER_TOOLS } from './builder/builder-tool-names';
import { withOpenSuspensions } from './utils/messages-envelope';
/**
* Builder side-effects: when the LLM streams arguments for `build_custom_tool`
* we re-emit each delta as a `code-delta` event so the FE editor can render
* incrementally; on tool completion we emit `config-updated` / `tool-updated`
* so the FE refreshes the corresponding panel. State is local to one request:
* `streamingToolName` tracks the tool whose arguments are currently streaming
* (replaces the old per-message-id heuristic).
*/
function makeBuilderToolEvents(send: (e: AgentSseEvent) => void): ToolEventCallbacks {
let streamingToolName: string | undefined;
return {
toolInputStart: (name) => {
streamingToolName = name;
},
toolInputDelta: (_toolCallId, delta) => {
if (streamingToolName === BUILDER_TOOLS.BUILD_CUSTOM_TOOL) {
send({ type: 'code-delta', delta });
}
},
toolResult: (name) => {
if (name === BUILDER_TOOLS.WRITE_CONFIG || name === BUILDER_TOOLS.PATCH_CONFIG) {
send({ type: 'config-updated' });
streamingToolName = undefined;
}
if (name === BUILDER_TOOLS.CREATE_SKILL) {
send({ type: 'config-updated' });
streamingToolName = undefined;
}
if (name === BUILDER_TOOLS.CREATE_TASK) {
send({ type: 'config-updated' });
streamingToolName = undefined;
}
if (name === BUILDER_TOOLS.BUILD_CUSTOM_TOOL) {
send({ type: 'tool-updated' });
streamingToolName = undefined;
}
},
};
}
@RestController('/projects/:projectId/agents/v2')
export class AgentBuilderController {
constructor(
private readonly agentsBuilderService: AgentsBuilderService,
private readonly credentialsService: CredentialsService,
private readonly agentsService: AgentsService,
) {}
@Get('/:agentId/build/messages')
@ProjectScope('agent:read')
async getBuilderMessages(
req: AuthenticatedRequest<{ projectId: string; agentId: string }>,
): Promise<AgentBuilderMessagesResponse> {
const { projectId, agentId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
// Merge persisted thread memory with any open suspension's checkpoint
// so a refresh during a suspended turn still returns the suspended
// assistant message (the SDK only saveToMemory's on completion).
// Scoped to the builder thread so preview-chat suspensions don't bleed in.
const memory = await this.agentsBuilderService.getBuilderMessages(agentId);
const checkpoint = await this.agentsBuilderService.findOpenBuilderCheckpoint(agentId);
return withOpenSuspensions(messagesToDto(memory), checkpoint);
}
@Delete('/:agentId/build/messages')
@ProjectScope('agent:update')
async clearBuilderMessages(req: AuthenticatedRequest<{ projectId: string; agentId: string }>) {
const { projectId, agentId } = req.params;
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
await this.agentsBuilderService.clearBuilderMessages(agentId);
return { ok: true };
}
@Post('/:agentId/build', { usesTemplates: true })
@ProjectScope('agent:update')
async build(
req: AuthenticatedRequest<{ projectId: string }>,
res: FlushableResponse,
@Param('agentId') agentId: string,
@Body payload: AgentChatMessageDto,
) {
const { projectId } = req.params;
const { message } = payload;
// Validate the agent exists before opening the SSE stream so a malformed
// id surfaces as a typed 404 instead of a generic 500 from the builder
// service's internal lookup.
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const credentialProvider = new AgentsCredentialProvider(
this.credentialsService,
projectId,
req.user,
);
const { send } = initSseStream(res);
try {
const suspended = await pumpChunks(
this.agentsBuilderService.buildAgent(
agentId,
projectId,
message,
credentialProvider,
req.user,
),
send,
makeBuilderToolEvents(send),
);
if (!suspended) {
send({ type: 'done' });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Build failed';
const errorCode =
error && typeof error === 'object' && 'code' in error
? (error as { code?: unknown }).code
: undefined;
send({
type: 'error',
message: errorMessage,
...(typeof errorCode === 'string' ? { code: errorCode } : {}),
});
}
res.end();
}
@Post('/:agentId/build/resume', { usesTemplates: true })
@ProjectScope('agent:update')
async buildResume(
req: AuthenticatedRequest<{ projectId: string }>,
res: FlushableResponse,
@Param('agentId') agentId: string,
@Body payload: AgentBuildResumeDto,
) {
const { projectId } = req.params;
const { runId, toolCallId, resumeData } = payload;
// Validate the agent exists before opening the SSE stream so a malformed
// id surfaces as a typed 404 instead of a generic 500 from the builder
// service's internal lookup.
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`);
const credentialProvider = new AgentsCredentialProvider(
this.credentialsService,
projectId,
req.user,
);
const { send } = initSseStream(res);
try {
const suspended = await pumpChunks(
this.agentsBuilderService.resumeBuild(
agentId,
projectId,
runId,
toolCallId,
resumeData,
credentialProvider,
req.user,
),
send,
makeBuilderToolEvents(send),
);
if (!suspended) {
send({ type: 'done' });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Resume failed';
send({ type: 'error', message: errorMessage });
}
res.end();
}
}

View File

@ -10,21 +10,8 @@ import { LoggerProxy } from 'n8n-workflow';
export type FlushableResponse = Response & { flush?: () => void };
/**
* Side-effect callbacks for the agent builder. Keyed off discrete tool events
* no more `messageId` turn tracking. `toolInputStart` lets the builder
* remember which tool is currently streaming arguments so it can route
* `toolInputDelta` text into the right side-effect (e.g. `code-delta`).
*/
export interface ToolEventCallbacks {
toolInputStart?: (toolName: string) => void;
toolInputDelta?: (toolCallId: string, delta: string) => void;
toolResult?: (toolName: string) => void;
}
interface ChunkHandlerCtx {
send: (e: AgentSseEvent) => void;
onToolEvent?: ToolEventCallbacks;
}
/**
@ -120,7 +107,7 @@ function emitToolChunk(
>,
ctx: ChunkHandlerCtx,
): { suspended: boolean } {
const { send, onToolEvent } = ctx;
const { send } = ctx;
switch (chunk.type) {
case 'tool-input-start':
@ -129,12 +116,10 @@ function emitToolChunk(
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
});
onToolEvent?.toolInputStart?.(chunk.toolName);
break;
case 'tool-input-delta':
if (chunk.delta) {
send({ type: 'tool-input-delta', toolCallId: chunk.toolCallId, delta: chunk.delta });
onToolEvent?.toolInputDelta?.(chunk.toolCallId, chunk.delta);
}
break;
case 'tool-call':
@ -172,7 +157,6 @@ function emitToolChunk(
...(chunk.isError !== undefined && { isError: chunk.isError }),
...(toolResultChunk.canceled !== undefined && { canceled: toolResultChunk.canceled }),
});
onToolEvent?.toolResult?.(chunk.toolName);
break;
}
case 'tool-call-suspended': {
@ -253,22 +237,14 @@ function stringifyError(error: unknown): string {
/**
* Pump SDK stream chunks through a typed AgentSseEvent stream.
*
* Side-effects (`config-updated` / `tool-updated` / `code-delta`) for the
* agent builder are surfaced via the `onToolEvent` callback so the chat path
* can ignore them.
*
* Returns `true` when a suspension was emitted (the run paused), `false`
* otherwise.
*/
export async function pumpChunks(
chunks: AsyncIterable<StreamChunk>,
send: (e: AgentSseEvent) => void,
onToolEvent?: ToolEventCallbacks,
): Promise<boolean> {
const ctx: ChunkHandlerCtx = {
send,
onToolEvent,
};
const ctx: ChunkHandlerCtx = { send };
for await (const chunk of chunks) {
const { suspended } = emitChunkEvents(chunk, ctx);

View File

@ -16,7 +16,6 @@ export class AgentsModule implements ModuleInterface {
await import('./agent-knowledge.controller');
await import('./agent-publish.controller');
await import('./agent-chat.controller');
await import('./agent-builder.controller');
await import('./agent-integrations.controller');
await import('./agent-vector-stores.controller');
await import('./agent-tasks.controller');

View File

@ -146,7 +146,6 @@ describe('builder model recommendations', () => {
expect(prompt).toContain('`agent-builder-sub-agents`');
expect(prompt).not.toContain('`delegate_subagent`');
expect(prompt).not.toContain('Use `list_sub_agents` to discover published same-project agents');
expect(prompt).not.toContain('call `ask_question` with `allowMultiple: true`');
});
it('tells the builder to preserve fallback web search on model switches', () => {

View File

@ -8,8 +8,10 @@ import type { CredentialsService } from '@/credentials/credentials.service';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import type { AiService } from '@/services/ai.service';
import { BUILDER_NOT_CONFIGURED_CODE } from '@n8n/api-types';
import { AgentsBuilderSettingsService } from '../agents-builder-settings.service';
import { BUILDER_NOT_CONFIGURED_CODE, BuilderNotConfiguredError } from '../errors';
import { BuilderNotConfiguredError } from '../errors';
const ENV_KEYS = ['N8N_AI_ANTHROPIC_KEY', 'ANTHROPIC_API_KEY'] as const;
@ -253,69 +255,13 @@ describe('AgentsBuilderSettingsService', () => {
});
});
describe('getStatus', () => {
it('mode=custom + resolvable credential → isConfigured: true', async () => {
mockPersistedSettings({
mode: 'custom',
provider: 'anthropic',
credentialId: 'cred-1',
modelName: 'claude-3-5-sonnet',
});
credentialsFinderService.findCredentialById.mockResolvedValue(
mock<CredentialsEntity>({ id: 'cred-1', type: 'anthropicApi' }),
);
await expect(service.getStatus()).resolves.toEqual({ isConfigured: true });
});
it('mode=custom + missing credential → isConfigured: false', async () => {
mockPersistedSettings({
mode: 'custom',
provider: 'anthropic',
credentialId: 'cred-1',
modelName: 'claude-3-5-sonnet',
});
credentialsFinderService.findCredentialById.mockResolvedValue(null);
await expect(service.getStatus()).resolves.toEqual({ isConfigured: false });
});
it('mode=default + proxy enabled → isConfigured: true', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(true);
await expect(service.getStatus()).resolves.toEqual({ isConfigured: true });
});
it('mode=default + proxy disabled + env set → isConfigured: true (env-var backstop counts)', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);
process.env.N8N_AI_ANTHROPIC_KEY = 'sk-env';
await expect(service.getStatus()).resolves.toEqual({ isConfigured: true });
});
it('mode=default + proxy disabled + ANTHROPIC_API_KEY set → isConfigured: true', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);
process.env.ANTHROPIC_API_KEY = 'sk-env';
await expect(service.getStatus()).resolves.toEqual({ isConfigured: true });
});
it('mode=default + proxy disabled + env empty → isConfigured: false', async () => {
mockPersistedSettings({ mode: 'default' });
aiService.isProxyEnabled.mockReturnValue(false);
await expect(service.getStatus()).resolves.toEqual({ isConfigured: false });
});
describe('getAdminSettings', () => {
it('no persisted settings → defaults to mode=default', async () => {
mockPersistedSettings(null);
aiService.isProxyEnabled.mockReturnValue(true);
const status = await service.getStatus();
expect(status).toEqual({ isConfigured: true });
const result = await service.getAdminSettings();
expect(result.settings).toEqual({ mode: 'default' });
});
});

View File

@ -11,22 +11,10 @@ import type { N8nMemory, N8nMemoryImpl } from '../../integrations/n8n-memory';
import type { AgentCheckpointRepository } from '../../repositories/agent-checkpoint.repository';
import type { NodeCatalogService } from '@/node-catalog';
import { AgentsBuilderService, resolveBuilderThreadId } from '../agents-builder.service';
import { AgentsBuilderService } from '../agents-builder.service';
import type { AgentsBuilderSettingsService } from '../agents-builder-settings.service';
import type { AgentsBuilderToolsService } from '../agents-builder-tools.service';
describe('resolveBuilderThreadId', () => {
it('defaults to the builder thread prefix', () => {
expect(resolveBuilderThreadId('agent-1')).toBe('builder:agent-1');
});
it('uses the override when provided', () => {
expect(resolveBuilderThreadId('agent-1', 'ia-builder:thread-9:agent-1')).toBe(
'ia-builder:thread-9:agent-1',
);
});
});
// The `Agent`/`Memory` SDK classes are dynamically imported inside
// `createBuilderAgent` (`await import('@n8n/agents')`). Stubbing them here lets
// us capture the persistence options passed to `builder.stream(...)` without
@ -194,9 +182,12 @@ function setup(
credentialProvider,
agentsBuilderToolsService,
builderSettings,
n8nCheckpointStorage,
};
}
const baseSession = { threadId: 'ia-builder:t:agent-1' };
describe('AgentsBuilderService session isolation', () => {
beforeEach(() => {
agentsSdkMocks.streamCalls.length = 0;
@ -206,13 +197,11 @@ describe('AgentsBuilderService session isolation', () => {
agentsSdkMocks.skillsCalls.length = 0;
});
it('uses the session threadId override for stream persistence when a session is provided', async () => {
it('uses the session threadId for stream persistence', async () => {
const { service, user, credentialProvider } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
}),
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
expect(agentsSdkMocks.streamCalls).toHaveLength(1);
@ -221,36 +210,12 @@ describe('AgentsBuilderService session isolation', () => {
);
});
it('defaults stream persistence to builder:<agentId> without a session', async () => {
const { service, user, credentialProvider } = setup();
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
expect(agentsSdkMocks.streamCalls).toHaveLength(1);
expect(agentsSdkMocks.streamCalls[0]?.options.persistence.threadId).toBe('builder:agent-1');
});
it('reads agents-UI builder chat history from builder:<agentId> regardless of prior instance-AI session usage', async () => {
const { service, memoryImplementation, user, credentialProvider } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
}),
);
await service.getBuilderMessages('agent-1');
expect(memoryImplementation.getMessages).toHaveBeenCalledWith('builder:agent-1');
expect(memoryImplementation.getMessages).not.toHaveBeenCalledWith('ia-builder:t:agent-1');
});
it('appends the session instructionsAddendum to the built prompt when provided', async () => {
const { service, user, credentialProvider } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
...baseSession,
instructionsAddendum: 'Extra sub-agent rules go here.',
}),
);
@ -263,7 +228,9 @@ describe('AgentsBuilderService session isolation', () => {
it('does not append anything to the prompt when instructionsAddendum is absent', async () => {
const { service, user, credentialProvider } = setup();
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
expect(agentsSdkMocks.instructionsCalls).toHaveLength(1);
expect(agentsSdkMocks.instructionsCalls[0]).not.toContain('Extra sub-agent rules');
@ -275,52 +242,30 @@ describe('AgentsBuilderService session isolation', () => {
shared: [fakeTool('ask_credential')],
});
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
expect(agentsSdkMocks.registeredToolNames).toEqual(
expect.arrayContaining(['resolve_llm', 'read_config', 'ask_credential']),
);
});
it('omits standard tools named in session.excludeTools while still registering the rest', async () => {
const { service, user, credentialProvider } = setup({
json: [fakeTool('ask_questions'), fakeTool('read_config')],
shared: [fakeTool('ask_credential')],
});
it('cancelCheckpoint expires the checkpoint scoped to the agent', async () => {
const { service, n8nCheckpointStorage } = setup();
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
excludeTools: ['ask_questions'],
}),
);
await service.cancelCheckpoint('agent-1', 'run-1');
expect(agentsSdkMocks.registeredToolNames).not.toContain('ask_questions');
expect(agentsSdkMocks.registeredToolNames).toEqual(
expect.arrayContaining(['read_config', 'ask_credential']),
);
expect(n8nCheckpointStorage.delete).toHaveBeenCalledWith('run-1', 'agent-1');
});
it('omits the integrations skill when session.excludeTools excludes configure_channel', async () => {
it('includes the integrations skill', 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'],
}),
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
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();
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
const skills = agentsSdkMocks.skillsCalls[0] as Array<{ id: string }>;
expect(skills.some((skill) => skill.id === 'agent-builder-integrations')).toBe(true);
});
@ -330,7 +275,7 @@ describe('AgentsBuilderService session isolation', () => {
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, {
threadId: 'ia-builder:t:agent-1',
...baseSession,
modelConfig: 'anthropic/claude-sonnet-host-resolved',
}),
);
@ -342,7 +287,9 @@ describe('AgentsBuilderService session isolation', () => {
it('falls back to resolveModelConfig when session.modelConfig is absent', async () => {
const { service, user, credentialProvider, builderSettings } = setup();
await drain(service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user));
await drain(
service.buildAgent('agent-1', 'project-1', 'hi', credentialProvider, user, baseSession),
);
expect(agentsSdkMocks.modelCalls).toEqual(['anthropic/claude-3-5-haiku']);
expect(builderSettings.resolveModelConfig).toHaveBeenCalledWith(user);

View File

@ -1,7 +1,6 @@
import {
AgentBuilderAdminSettingsUpdateDto,
type AgentBuilderAdminSettingsResponse,
type AgentBuilderStatusResponse,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Get, GlobalScope, Patch, RestController } from '@n8n/decorators';
@ -38,9 +37,4 @@ export class AgentsBuilderSettingsController {
await this.settingsService.updateAdminSettings(parseResult.data);
return await this.settingsService.getAdminSettings();
}
@Get('/status')
async getStatus(): Promise<AgentBuilderStatusResponse> {
return await this.settingsService.getStatus();
}
}

View File

@ -97,30 +97,10 @@ export class AgentsBuilderSettingsService {
this.cached = settings;
}
/** Get the persisted admin settings + the derived `isConfigured` flag. */
/** Get the persisted admin settings. */
async getAdminSettings(): Promise<AgentBuilderAdminSettingsResponse> {
const settings = await this.loadSettings();
const isConfigured = await this.computeIsConfigured(settings);
return { settings, isConfigured };
}
/** Lightweight readiness check used by the builder UI to gate the input box. */
async getStatus(): Promise<{ isConfigured: boolean }> {
const settings = await this.loadSettings();
const isConfigured = await this.computeIsConfigured(settings);
return { isConfigured };
}
private async computeIsConfigured(settings: AgentBuilderAdminSettings): Promise<boolean> {
if (settings.mode === 'custom') {
const credential = await this.credentialsFinderService.findCredentialById(
settings.credentialId,
);
return !!credential;
}
// mode === 'default' — true if any of the runtime resolution branches
// can succeed: AI proxy enabled, or the env-var backstop is set.
return this.aiService.isProxyEnabled() || !!readEnvAnthropicKey();
return { settings };
}
/**

View File

@ -11,7 +11,7 @@ import { AgentsConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { IsNull } from '@n8n/typeorm';
import { jsonParse, UserError } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { NodeCatalogService } from '@/node-catalog';
@ -26,7 +26,7 @@ import { streamAgentChunks } from '../utils/agent-stream';
import { buildAgentPreviewPath } from './agent-builder-preview-path';
import { buildBuilderPrompt } from './agents-builder-prompts';
import { AgentsBuilderToolsService, getAgentConfigHash } from './agents-builder-tools.service';
import { AGENT_THREAD_PREFIX } from './builder-tool-names';
import { BuilderCheckpointUnavailableError } from './errors';
import { AgentsBuilderSettingsService } from './agents-builder-settings.service';
import { buildBuilderTelemetry } from '../tracing/builder-telemetry';
import { getModelRecommendationsSection } from './agents-builder-model-recommendations';
@ -38,8 +38,8 @@ interface FindSuspendedCheckpointOptions {
/** Options for a builder session that isn't the default agents-UI chat (e.g. an instance-AI sub-agent). */
export interface BuilderSessionOptions {
/** Overrides the persistence thread id. Default: `builder:<agentId>`. */
threadId?: string;
/** Persistence thread id for this builder session. */
threadId: string;
/** Extra text appended to the builder prompt (e.g. instance-AI sub-agent rules). */
instructionsAddendum?: string;
/**
@ -48,21 +48,8 @@ export interface BuilderSessionOptions {
* Used by hosts (e.g. instance AI) that already resolved a model upstream.
*/
modelConfig?: ModelConfig;
/**
* Tool names to omit for this session, e.g. interactive tools with no UI on
* the host surface.
*/
excludeTools?: string[];
}
/** Derive the builder chat thread ID; callers may override (e.g. instance-AI sessions). */
export function resolveBuilderThreadId(agentId: string, override?: string): string {
return override ?? `${AGENT_THREAD_PREFIX.BUILDER}${agentId}`;
}
/** Derive a stable thread ID for the builder chat of a given agent. */
const builderThreadId = resolveBuilderThreadId;
@Service()
export class AgentsBuilderService {
constructor(
@ -77,27 +64,6 @@ export class AgentsBuilderService {
private readonly agentsConfig: AgentsConfig,
) {}
// ---------------------------------------------------------------------------
// Public — message storage
// ---------------------------------------------------------------------------
/**
* Return persisted builder chat messages for an agent.
*/
async getBuilderMessages(agentId: string) {
const threadId = builderThreadId(agentId);
return await this.n8nMemory.getImplementation(agentId).getMessages(threadId);
}
/**
* Clear persisted builder chat messages for an agent.
*/
async clearBuilderMessages(agentId: string) {
const threadId = builderThreadId(agentId);
const memory = this.n8nMemory.getImplementation(agentId);
await memory.deleteMessagesByThread(threadId);
await memory.deleteThread(threadId);
}
// ---------------------------------------------------------------------------
// Public — streaming
// ---------------------------------------------------------------------------
@ -108,7 +74,7 @@ export class AgentsBuilderService {
message: string,
credentialProvider: CredentialProvider,
user: User,
session?: BuilderSessionOptions,
session: BuilderSessionOptions,
): AsyncGenerator<StreamChunk> {
const builder = await this.createBuilderAgent(
agentId,
@ -122,7 +88,7 @@ export class AgentsBuilderService {
const resourceId = user.id;
const resultStream = await builder.stream(message, {
persistence: { threadId: resolveBuilderThreadId(agentId, session?.threadId), resourceId },
persistence: { threadId: session.threadId, resourceId },
});
yield* this.streamFromAgent(resultStream);
@ -131,12 +97,13 @@ export class AgentsBuilderService {
/**
* Resume a suspended builder tool call and yield the resulting stream chunks.
*
* The `runId` is supplied by the caller it originates either from the
* `tool-call-suspended` chunk the FE just received (live) or from the
* `openSuspensions` sidecar returned by `GET /build/messages` (history
* reload). A fresh builder agent is reconstructed every time; the SDK's
* `agent.resume(...)` rehydrates the suspended state from the persisted
* checkpoint, so the new instance picks up where the old one left off.
* The `runId` is supplied by the caller it originates from the live
* `tool-call-suspended` chunk, from the `openSuspensions` sidecar returned
* by the chat controller's messages endpoints (history reload), or from
* the instance-AI delegate's `findOpenSuspensions`. A fresh builder agent
* is reconstructed every time; the SDK's `agent.resume(...)` rehydrates
* the suspended state from the persisted checkpoint, so the new instance
* picks up where the old one left off.
*/
async *resumeBuild(
agentId: string,
@ -146,14 +113,22 @@ export class AgentsBuilderService {
resumeData: unknown,
credentialProvider: CredentialProvider,
user: User,
session?: BuilderSessionOptions,
session: BuilderSessionOptions,
): AsyncGenerator<StreamChunk> {
const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId);
if (checkpointStatus.status === 'expired') {
throw new UserError(`Builder checkpoint ${runId} has expired and cannot be resumed`);
this.logger.debug('Builder checkpoint unavailable', {
runId,
status: checkpointStatus.status,
});
throw new BuilderCheckpointUnavailableError('expired');
}
if (checkpointStatus.status === 'not-found') {
throw new UserError(`Builder checkpoint ${runId} not found`);
this.logger.debug('Builder checkpoint unavailable', {
runId,
status: checkpointStatus.status,
});
throw new BuilderCheckpointUnavailableError('not-found');
}
const builder = await this.createBuilderAgent(
@ -174,6 +149,11 @@ export class AgentsBuilderService {
yield* this.streamFromAgent(resultStream);
}
/** Expire a suspended builder checkpoint (e.g. when a host cannot render its question), scoped to the agent that owns it. */
async cancelCheckpoint(agentId: string, runId: string): Promise<void> {
await this.n8nCheckpointStorage.delete(runId, agentId);
}
// ---------------------------------------------------------------------------
// Private — builder agent construction
// ---------------------------------------------------------------------------
@ -192,7 +172,7 @@ export class AgentsBuilderService {
projectId: string,
credentialProvider: CredentialProvider,
user: User,
session?: BuilderSessionOptions,
session: BuilderSessionOptions,
): Promise<RuntimeAgent> {
const agent = await this.agentsService.findById(agentId, projectId);
if (!agent) {
@ -213,7 +193,7 @@ export class AgentsBuilderService {
// directly and skip the builder's own settings chain entirely — no
// `BuilderNotConfiguredError` is possible on this path, and there is no
// tracing-proxy config to forward since it isn't the builder's own proxy.
const { config: modelConfig, tracingProxyConfig } = session?.modelConfig
const { config: modelConfig, tracingProxyConfig } = session.modelConfig
? { config: session.modelConfig, tracingProxyConfig: undefined }
: await this.builderSettings.resolveModelConfig(user);
@ -236,10 +216,10 @@ export class AgentsBuilderService {
modelRecommendationsSection,
enabledModules,
});
const finalInstructions = session?.instructionsAddendum
const finalInstructions = session.instructionsAddendum
? `${instructions}\n\n${session.instructionsAddendum}`
: instructions;
const runtimeSkills = getBuilderRuntimeSkills(session?.excludeTools);
const runtimeSkills = getBuilderRuntimeSkills();
const tools = this.agentsBuilderToolsService.getTools(
agentId,
@ -266,15 +246,13 @@ export class AgentsBuilderService {
agentId,
projectId,
userId: user.id,
threadId: resolveBuilderThreadId(agentId, session?.threadId),
threadId: session.threadId,
model: modelConfig,
tracingProxyConfig,
});
if (telemetry) builder.telemetry(telemetry);
const excludeTools = new Set(session?.excludeTools ?? []);
for (const tool of [...tools.json, ...tools.shared]) {
if (excludeTools.has(tool.name)) continue;
builder.tool(tool);
}
@ -319,15 +297,6 @@ export class AgentsBuilderService {
return await this.findSuspendedCheckpoint(agentId, threadId, options);
}
/**
* Like {@link findOpenCheckpointForThread}, scoped to the builder thread for
* this agent. Prevents preview-chat suspensions from bleeding into builder
* history.
*/
async findOpenBuilderCheckpoint(agentId: string): Promise<SerializableAgentState | null> {
return await this.findSuspendedCheckpoint(agentId, builderThreadId(agentId));
}
private async findSuspendedCheckpoint(
agentId: string,
threadId?: string,

View File

@ -1,6 +1,6 @@
/**
* Tool names used by the agent builder. Centralised so prompts, the SSE event
* routing, and tests can't drift on string typos.
* Tool names used by the agent builder. Centralised so tool implementations,
* prompts, and tests can't drift on string typos.
*
* The interactive tools (`ask_credential`, `ask_embedding_credential`,
* `ask_questions`, `configure_channel`) are NOT listed here their names live
@ -26,8 +26,7 @@ export const BUILDER_TOOLS = {
export type BuilderToolName = (typeof BUILDER_TOOLS)[keyof typeof BUILDER_TOOLS];
/** Thread-id prefixes scoping different chat surfaces of the same agent. */
/** Thread-id prefix scoping the test-chat surface of an agent. */
export const AGENT_THREAD_PREFIX = {
TEST: 'test-',
BUILDER: 'builder:',
} as const;

View File

@ -1,16 +1,11 @@
import { BUILDER_NOT_CONFIGURED_CODE } from '@n8n/api-types';
import { BUILDER_CHECKPOINT_UNAVAILABLE_CODE, BUILDER_NOT_CONFIGURED_CODE } from '@n8n/api-types';
import { UserError } from 'n8n-workflow';
/**
* Stable code on `BuilderNotConfiguredError` so the SSE stream / FE can
* detect the unconfigured state and render the "go to settings" empty
* state without parsing the human-readable message.
*
* Re-exported from `@n8n/api-types` (the canonical source) so existing cli
* imports of this module keep working.
* Stable code on `BuilderNotConfiguredError` so callers that can't import
* the class (e.g. instance AI's `build-agent.tool.ts`) can detect the
* unconfigured state by matching the thrown error's `code`.
*/
export { BUILDER_NOT_CONFIGURED_CODE };
export class BuilderNotConfiguredError extends UserError {
readonly code = BUILDER_NOT_CONFIGURED_CODE;
@ -20,3 +15,15 @@ export class BuilderNotConfiguredError extends UserError {
);
}
}
export class BuilderCheckpointUnavailableError extends UserError {
readonly code = BUILDER_CHECKPOINT_UNAVAILABLE_CODE;
constructor(reason: 'expired' | 'not-found') {
super(
reason === 'expired'
? 'The builder question this answer belongs to has expired and can no longer be resumed.'
: 'The builder question this answer belongs to no longer exists.',
);
}
}

View File

@ -18,24 +18,10 @@ function makeCtx(overrides?: { resumeData?: unknown }): TestCtx {
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 () => {
it('suspends a single single-select question with exactly one option', async () => {
const ctx = makeCtx();
await tool.handler!(
{ questions: [{ question: 'Pick subagents', type: 'multi', options: ['agent-research'] }] },
{ questions: [{ question: 'Pick one', type: 'single', options: ['slack'] }] },
ctx as unknown as InterruptibleToolContext,
);

View File

@ -64,22 +64,6 @@ function withDefaultIds(
});
}
/** 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[],
@ -111,9 +95,7 @@ export function buildAskQuestionsTool(): BuiltTool {
'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.',
'it. Returns { answered: false } on dismissal, or { answered: true, answers } otherwise.',
)
.input(askQuestionsInputSchema)
.suspend(questionsSuspendPayloadSchema)
@ -126,11 +108,6 @@ export function buildAskQuestionsTool(): BuiltTool {
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',

View File

@ -1,23 +1,9 @@
import { CONFIGURE_CHANNEL_TOOL_NAME } from '@n8n/api-types';
import { getBuilderRuntimeSkills } from '../index';
describe('getBuilderRuntimeSkills', () => {
it('includes the integrations skill by default', () => {
it('includes the integrations skill', () => {
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);
});
});

View File

@ -1,5 +1,4 @@
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';
@ -8,15 +7,8 @@ import { subAgentsSkill } from './sub-agents.skill';
import { targetSkillsSkill } from './target-skills.skill';
import { targetTasksSkill } from './target-tasks.skill';
/**
* `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[] = [
export function getBuilderRuntimeSkills(): RuntimeSkill[] {
return [
integrationsSkill(),
mcpSkill(),
resourceLocatorsSkill(),
@ -28,10 +20,4 @@ export function getBuilderRuntimeSkills(excludeTools: string[] = []): RuntimeSki
// instead of merely loading instructions that tell it to research.
// researchSkill(),
];
if (excludeTools.includes(CONFIGURE_CHANNEL_TOOL_NAME)) {
return skills.filter((skill) => skill.id !== 'agent-builder-integrations');
}
return skills;
}

View File

@ -1,10 +1,4 @@
import type { CredentialProvider, StreamChunk } from '@n8n/agents';
import {
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';
import type {
@ -21,30 +15,12 @@ import { AgentsService } from './agents.service';
import { AgentsBuilderService } from './builder/agents-builder.service';
import type { BuilderSessionOptions } from './builder/agents-builder.service';
/**
* Standard builder tools that require user interaction (chat cards). None of
* them have a rendering surface in instance-AI chat, so they are excluded
* from the builder's sub-agent session the builder must complete every
* turn and report open questions as reply text instead of suspending.
*/
export const NON_INTERACTIVE_EXCLUDED_TOOL_NAMES: string[] = [
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
];
/** 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_questions, ask_credential, ask_embedding_credential, configure_channel) are not available in this session.
You are running as a sub-agent inside n8n's instance AI chat; the user sees your questions as chat cards.
- 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.
- When a decision genuinely needs the user (model choice with no default, missing credential, channel setup), finish the turn and list those open questions clearly at the end of your reply text the host assistant will ask the user and send you the answers in a follow-up message.
- Credentials and chat channels cannot be connected from this chat; describe what the user must connect and continue with the rest of the build.
- For the model: when the instructions specify or imply a provider/model, call resolve_llm directly; otherwise pick the recommended default and note it in your reply.
- The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.`;
The agent preview link is not visible in this chat; describe outcomes in text instead of linking the preview.`;
function isTextDeltaChunk(
chunk: StreamChunk,
@ -78,12 +54,14 @@ function toBuilderTurnStream(chunks: AsyncGenerator<StreamChunk>): BuilderTurnSt
/**
* Host implementation of the instance-ai builder-delegate port. Wraps
* `AgentsBuilderService` for use as a narrow, non-interactive sub-agent by
* instance AI's build-agent tool: one builder conversational turn per
* `streamBuild` call, with builder sessions keyed to an instance-AI-scoped
* thread id (`session.threadId`) so nothing surfaces in the agents-module
* builder UI. `createDelegate` returns a per-request object bound to the
* calling user + project.
* `AgentsBuilderService` for use as a sub-agent by instance AI's build-agent
* tool: one builder conversational turn per `streamBuild`/`resumeBuild` call,
* with builder sessions keyed to an instance-AI-scoped thread id
* (`session.threadId`) so nothing surfaces in the agents-module builder UI.
* The builder's interactive tools stay enabled suspensions are surfaced to
* the caller via `findOpenSuspensions`/`resumeBuild` so it can cascade them
* through its own suspend/resume. `createDelegate` returns a per-request
* object bound to the calling user + project.
*/
@Service()
export class InstanceAiBuilderDelegateAdapterService {
@ -92,17 +70,12 @@ export class InstanceAiBuilderDelegateAdapterService {
private readonly agentsBuilderService: AgentsBuilderService,
) {}
/**
* Builder session options for the sub-agent surface: excludes every
* interactive tool (no card UI in this chat) and appends the sub-agent
* prompt rules that explain the non-interactive contract.
*/
/** Builder session options for the sub-agent surface: appends the sub-agent prompt rules. */
private buildSubAgentSession(session: BuilderDelegateSession): BuilderSessionOptions {
return {
threadId: session.threadId,
instructionsAddendum: INSTANCE_AI_BUILDER_ADDENDUM,
modelConfig: session.modelConfig,
excludeTools: NON_INTERACTIVE_EXCLUDED_TOOL_NAMES,
};
}
@ -141,6 +114,39 @@ export class InstanceAiBuilderDelegateAdapterService {
),
);
},
resumeBuild: async (agentId, resume, session) => {
await assertProjectScope('agent:update');
return toBuilderTurnStream(
this.agentsBuilderService.resumeBuild(
agentId,
projectId,
resume.runId,
resume.toolCallId,
resume.resumeData,
credentialProvider,
user,
this.buildSubAgentSession(session),
),
);
},
findOpenSuspensions: async (agentId, session) => {
await assertProjectScope('agent:update');
const checkpoint = await this.agentsBuilderService.findOpenCheckpointForThread(
agentId,
session.threadId,
);
if (!checkpoint) return [];
return Object.values(checkpoint.pendingToolCalls ?? {})
.filter((tc) => tc.suspended)
.map((tc) => ({ runId: tc.runId, toolCallId: tc.toolCallId }));
},
cancelOpenSuspension: async (agentId, runId) => {
await assertProjectScope('agent:update');
await this.agentsBuilderService.cancelCheckpoint(agentId, runId);
},
};
}
}

View File

@ -63,4 +63,26 @@ describe('N8NCheckpointStorage', () => {
);
await expect(service.claimForResume('run-1', suspendedState)).resolves.toBe(false);
});
it('expires a checkpoint by runId when no agentId is given', async () => {
const { service, repository } = makeService();
await service.delete('run-1');
expect(repository.update).toHaveBeenCalledWith(
{ runId: 'run-1' },
{ expired: true, state: null },
);
});
it('scopes the expiry to the given agentId', async () => {
const { service, repository } = makeService();
await service.delete('run-1', 'agent-1');
expect(repository.update).toHaveBeenCalledWith(
{ runId: 'run-1', agentId: 'agent-1' },
{ expired: true, state: null },
);
});
});

View File

@ -109,8 +109,11 @@ export class N8NCheckpointStorage {
return { status: 'active', checkpoint: jsonParse<SerializableAgentState>(checkpoint.state) };
}
async delete(key: string): Promise<void> {
await this.agentCheckpointRepository.update({ runId: key }, { expired: true, state: null });
async delete(key: string, agentId?: string): Promise<void> {
await this.agentCheckpointRepository.update(
{ runId: key, ...(agentId !== undefined ? { agentId } : {}) },
{ expired: true, state: null },
);
}
@OnLeaderTakeover()

View File

@ -75,8 +75,9 @@ function mergeOpenSuspendedToolCalls(
/**
* Merge an open suspended checkpoint into already-persisted history and
* surface the open-suspensions sidecar (toolCallId + runId) so the FE can
* re-arm suspended interactive cards after a refresh. Same contract as
* GET /build/messages.
* re-arm suspended interactive cards after a refresh. Used by the chat
* controller's `GET /:agentId/chat/:threadId/messages` and
* `GET /:agentId/chat/messages` envelopes.
*
* The input `messages` must already be in DTO form (the caller converts raw
* memory before passing it here). Checkpoint messages are converted here so

View File

@ -1,15 +1,20 @@
import {
cleanStoredUserMessage,
extractEditorContextWorkflowAttachments,
extractEditorContextResourceAttachments,
withCurrentDateTime,
AUTO_FOLLOW_UP_MESSAGE,
} from '../internal-messages';
type EditorContextAttachment =
| { type: 'workflow'; id: string; name?: string; executionId?: string }
| { type: 'agent'; id: string; name?: string; projectId: string };
/** Mirrors the marker the service writes in buildContextResourcesBlock. */
function editorContextMarker(
workflows: Array<{ type: 'workflow'; id: string; name?: string; executionId?: string }>,
attachments: EditorContextAttachment[],
prose = 'The user opened this conversation from the workflow editor.',
): string {
return `<editor-context>\n${JSON.stringify(workflows)}\n\nThe user opened this conversation from the workflow editor.\n</editor-context>`;
return `<editor-context>\n${JSON.stringify(attachments)}\n\n${prose}\n</editor-context>`;
}
function credentialContextMarker(): string {
@ -98,22 +103,46 @@ describe('cleanStoredUserMessage', () => {
});
});
describe('extractEditorContextWorkflowAttachments', () => {
describe('extractEditorContextResourceAttachments', () => {
it('reconstructs workflow attachments from the marker', () => {
const stored = editorContextMarker([
{ type: 'workflow', id: 'wf-1', name: 'My workflow', executionId: '6669' },
]);
expect(extractEditorContextWorkflowAttachments(stored)).toEqual([
expect(extractEditorContextResourceAttachments(stored)).toEqual([
{ type: 'workflow', id: 'wf-1', name: 'My workflow', executionId: '6669' },
]);
});
it('reconstructs agent attachments from the marker', () => {
const stored = editorContextMarker(
[{ type: 'agent', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' }],
'The user opened this conversation from the agent editor.',
);
expect(extractEditorContextResourceAttachments(stored)).toEqual([
{ type: 'agent', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' },
]);
});
it('reconstructs mixed workflow and agent attachments from the marker', () => {
const stored = editorContextMarker(
[
{ type: 'workflow', id: 'wf-1', name: 'My workflow' },
{ type: 'agent', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' },
],
'prose',
);
expect(extractEditorContextResourceAttachments(stored)).toEqual([
{ type: 'workflow', id: 'wf-1', name: 'My workflow' },
{ type: 'agent', id: 'agent-1', name: 'Support Agent', projectId: 'proj-1' },
]);
});
it('returns an empty array for a message without an editor-context block', () => {
expect(extractEditorContextWorkflowAttachments('Just a normal message')).toEqual([]);
expect(extractEditorContextResourceAttachments('Just a normal message')).toEqual([]);
});
it('returns an empty array when the marker JSON is invalid', () => {
const stored = '<editor-context>\nnot json\n\nprose\n</editor-context>';
expect(extractEditorContextWorkflowAttachments(stored)).toEqual([]);
expect(extractEditorContextResourceAttachments(stored)).toEqual([]);
});
});

View File

@ -359,7 +359,10 @@ export class InstanceAiAdapterService {
if (!Container.get(ModuleRegistry).isActive('agents')) return null;
try {
return Container.get(InstanceAiBuilderDelegateAdapterService);
} catch {
} catch (error) {
this.logger.warn('Failed to resolve builder delegate adapter; agent building disabled', {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}

View File

@ -160,7 +160,7 @@ export class InstanceAiController {
// Verify the requesting user owns this thread (or it's new)
await this.assertThreadAccess(req.user.id, threadId, { allowNew: true });
// Only file attachments carry a mime type to validate; workflow
// Only file attachments carry a mime type to validate; workflow and agent
// attachments are resource references the agent resolves with its tools.
const fileAttachments = (payload.attachments ?? []).filter(
(attachment) => attachment.type === 'file',

View File

@ -5,7 +5,9 @@ import {
buildProxyHeaders,
type InstanceAiAttachment,
type InstanceAiHandoffContext,
type InstanceAiAgentAttachment,
type InstanceAiFileAttachment,
type InstanceAiResourceAttachment,
type InstanceAiWorkflowAttachment,
type InstanceAiConfirmRequest,
type InstanceAiConfirmResponse,
@ -177,25 +179,31 @@ function getErrorMessage(error: unknown): string {
}
/**
* Renders a message's workflow attachments (e.g. a workflow + execution handed
* off from the editor) as a context block telling the agent what the user is
* looking at. Informative only: the agent should greet the user and ask how it
* Renders a message's resource attachments (e.g. a workflow + execution, or an
* agent, handed off from the editor) as a context block telling the agent what
* the user is looking at. Informative only: the agent should greet the user and ask how it
* can help rather than inspecting the resources up front. The ids stay in the
* block so they're available once the user actually asks for something.
* Returns an empty string when there are none.
*/
function buildContextResourcesBlock(workflowAttachments: InstanceAiWorkflowAttachment[]): string {
if (workflowAttachments.length === 0) return '';
const lines = workflowAttachments.map((attachment) => {
function buildContextResourcesBlock(contextAttachments: InstanceAiResourceAttachment[]): string {
if (contextAttachments.length === 0) return '';
const lines = contextAttachments.map((attachment) => {
const name = attachment.name ? ` "${attachment.name}"` : '';
if (attachment.type === 'agent') {
return `- Agent${name} (id: \`${attachment.id}\`, in project \`${attachment.projectId}\`).`;
}
// Only mention the execution when one was actually handed off.
const execution = attachment.executionId
? `, currently viewing its execution \`${attachment.executionId}\``
: '';
return `- Workflow${name} (id: \`${attachment.id}\`)${execution}.`;
});
const header = contextAttachments.some((attachment) => attachment.type === 'agent')
? 'The user opened this conversation from the agent editor, where they are looking at:'
: 'The user opened this conversation from the workflow editor, where they are looking at:';
const prose = [
'The user opened this conversation from the workflow editor, where they are looking at:',
header,
...lines,
"Treat this purely as context. Until the user tells you what they need, don't read, inspect, run, or otherwise call tools on these resources, and don't make claims about their contents — just briefly acknowledge what they're working on and ask how you can help.",
].join('\n');
@ -203,7 +211,7 @@ function buildContextResourcesBlock(workflowAttachments: InstanceAiWorkflowAttac
// (cleanStoredUserMessage) and the parser can reconstruct the attachments on
// reload from the leading JSON line — keeping the resource durable without
// persisting it as visible text.
return `${EDITOR_CONTEXT_OPEN_TAG}\n${JSON.stringify(workflowAttachments)}\n\n${prose}\n${EDITOR_CONTEXT_CLOSE_TAG}`;
return `${EDITOR_CONTEXT_OPEN_TAG}\n${JSON.stringify(contextAttachments)}\n\n${prose}\n${EDITOR_CONTEXT_CLOSE_TAG}`;
}
function buildHandoffContextBlock(context: InstanceAiHandoffContext | undefined): string {
@ -2915,6 +2923,13 @@ export class InstanceAiService {
const workflowAttachments = (attachments ?? []).filter(
(attachment): attachment is InstanceAiWorkflowAttachment => attachment.type === 'workflow',
);
const agentAttachments = (attachments ?? []).filter(
(attachment): attachment is InstanceAiAgentAttachment => attachment.type === 'agent',
);
const contextAttachments: InstanceAiResourceAttachment[] = [
...workflowAttachments,
...agentAttachments,
];
const signal = abortController.signal;
let tracing: InstanceAiTraceContext | undefined;
@ -3111,7 +3126,7 @@ export class InstanceAiService {
// the LLM title pass doesn't summarize the internal context block.
const thread = await memory.getThread(threadId);
if (thread && !thread.title) {
const handoffTitle = workflowAttachments.find((attachment) => attachment.name)?.name;
const handoffTitle = contextAttachments.find((attachment) => attachment.name)?.name;
await patchThread(memory, {
threadId,
update: ({ metadata }) =>
@ -3135,7 +3150,7 @@ export class InstanceAiService {
}
const enrichedMessage = await this.buildMessageWithRunningTasks(threadId, message);
const contextResourcesBlock = buildContextResourcesBlock(workflowAttachments);
const contextResourcesBlock = buildContextResourcesBlock(contextAttachments);
const handoffContextBlock = buildHandoffContextBlock(handoffContext);
let nonStructuredAttachments: InstanceAiFileAttachment[] = [];

View File

@ -1,6 +1,6 @@
import {
instanceAiWorkflowAttachmentSchema,
type InstanceAiWorkflowAttachment,
instanceAiResourceAttachmentSchema,
type InstanceAiResourceAttachment,
} from '@n8n/api-types';
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod';
@ -68,17 +68,18 @@ export function cleanStoredUserMessage(stored: string): string | null {
}
/**
* Reconstructs the workflow attachments the editor hand-off encoded in a stored
* user message, so the UI can re-surface them as artifacts after a reload.
* Returns an empty array when the message carries no editor context.
* Reconstructs the resource attachments (workflows, agents) the editor hand-off
* encoded in a stored user message, so the UI can re-surface them as artifacts
* after a reload. Returns an empty array when the message carries no editor
* context.
*/
export function extractEditorContextWorkflowAttachments(
export function extractEditorContextResourceAttachments(
stored: string,
): InstanceAiWorkflowAttachment[] {
): InstanceAiResourceAttachment[] {
const match = EDITOR_CONTEXT_JSON.exec(stored);
if (!match) return [];
const parsed = z
.array(instanceAiWorkflowAttachmentSchema)
.array(instanceAiResourceAttachmentSchema)
.safeParse(jsonParse(match[1], { fallbackValue: undefined }));
return parsed.success ? parsed.data : [];
}

View File

@ -11,7 +11,7 @@ import { z } from 'zod';
import {
cleanStoredUserMessage,
extractEditorContextWorkflowAttachments,
extractEditorContextResourceAttachments,
} from './internal-messages';
type RunSnapshots = AgentTreeSnapshot[];
@ -418,9 +418,9 @@ export function parseStoredMessages(
const content = cleanStoredUserMessage(text);
if (content === null) continue;
// Rebuild the editor hand-off's workflow attachments so the UI can
// re-surface them (chip + artifact) after a reload.
const attachments = extractEditorContextWorkflowAttachments(text);
// Rebuild the editor hand-off's resource attachments (workflow/agent) so
// the UI can re-surface them (chip + artifact) after a reload.
const attachments = extractEditorContextResourceAttachments(text);
messages.push({
id: msg.id,

View File

@ -4857,10 +4857,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.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.",
"agents.builder.unconfigured.cta": "Open agent builder settings",
"settings.n8nAgent.enable.label": "Enable AI Assistant",
"settings.n8nAgent.enable.description": "Enables the feature for all users in the instance",
"settings.n8nAgent.computerUse.label": "Enable Computer Use for AI Assistant",
@ -6738,19 +6734,10 @@
"agents.delete.modal.button.delete": "Delete",
"agents.builder.loadError": "Could not load agent",
"agents.builder.saveError": "Could not save agent settings",
"agents.builder.chatColumn.ariaLabel": "Chat",
"agents.builder.chatMode.build": "Build",
"agents.builder.chatMode.test": "Test",
"agents.builder.chatMode.ariaLabel": "Switch between builder and agent chat",
"agents.builder.chatMode.test.lockedTooltip": "Finish building your agent to chat to it",
"agents.builder.chat.sessionPicker.ariaLabel": "Session history",
"agents.builder.chat.sessionPicker.empty": "No previous chats",
"agents.builder.chat.newChat.ariaLabel": "Start a new chat",
"agents.builder.chat.newChat.label": "New chat",
"agents.builder.chat.hide.ariaLabel": "Hide builder",
"agents.builder.chat.show.ariaLabel": "Show builder",
"agents.builder.chat.fullWidth.expand.ariaLabel": "Expand",
"agents.builder.chat.fullWidth.collapse.ariaLabel": "Collapse",
"agents.chat.loadHistory.error": "Could not load chat history",
"agents.chat.clearHistory.error": "Could not clear chat history",
"agents.chat.clearHistory": "Clear chat history",
@ -6765,13 +6752,9 @@
"agents.chat.misconfigured.missing.webSearch.credential": "Web search credential",
"agents.chat.misconfigured.missing.agent": "Agent",
"agents.chat.misconfigured.missing.skill": "Skill ({id})",
"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.emptyState.title": "Chat with your agent",
"agents.chat.emptyState.description": "Send a message to start a conversation",
"agents.chat.toolNames.webSearch": "Web search",
"agents.chat.toolNames.findFile": "Find file",
"agents.chat.toolNames.searchText": "Search text",
@ -6895,14 +6878,7 @@
"agents.toolConfig.mcpApproval.refresh.hint": "Refresh the list of tools from this MCP server.",
"agents.toolConfig.mcpApproval.tools.placeholder": "Select tools",
"agents.toolConfig.mcpApproval.loadError": "Could not load MCP tools. Check the server settings and try again.",
"agents.new.title": "New agent",
"agents.new.defaultName": "New Agent",
"agents.new.startBlank": "Start from blank",
"agents.new.heading": "What should we build?",
"agents.new.headingWithName": "What should we build, {name}?",
"agents.new.description.placeholder": "Describe your agent...",
"agents.new.templates.label": "Or try a template",
"agents.builder.readonly.placeholder": "You don't have permission to edit this agent",
"agents.builder.sections.agent": "Agent",
"agents.builder.sections.advanced": "Advanced",
"agents.builder.sections.configJson": "Raw",
@ -7077,10 +7053,6 @@
"agents.builder.episodicMemoryCredentialModal.description": "An OpenAI credential is used to create embeddings for Episodic Memory.",
"agents.builder.editor.copy": "Copy to clipboard",
"agents.builder.editor.copied": "Copied",
"agents.builder.progress.building.title": "Building your agent...",
"agents.builder.progress.building.hint": "This usually takes 3060 seconds. Sit tight.",
"agents.builder.progress.error.title": "Something went wrong",
"agents.builder.progress.error.hint": "Check the log below and try again.",
"agents.builder.tools.title": "Tools",
"agents.builder.tools.count": "{count} tool configured | {count} tools configured",
"agents.builder.tools.add": "Add tool",
@ -7239,8 +7211,6 @@
"agents.builder.evaluations.type.judge": "Judge",
"agents.builder.evaluations.credentialConfigured": "Credential configured",
"agents.builder.evaluations.emptyPrefix": "No evaluations configured.",
"agents.builder.quickActions.addTool": "Add tool",
"agents.builder.quickActions.addTrigger": "Add channel",
"agents.builder.quickActions.memoriesUsed.count": "{count} memory | {count} memories",
"agents.builder.quickActions.memoriesUsed.keyMemory": "Memory",
"agents.builder.capabilities.title": "Capabilities",

View File

@ -16,7 +16,7 @@ import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyTo
import EmptyStateBuilderPrompt from '@/experiments/emptyStateBuilderPrompt/components/EmptyStateBuilderPrompt.vue';
import AppSelectionPage from '@/experiments/credentialsAppSelection/components/AppSelectionPage.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
import { useAgentTelemetry } from '@/features/agents/composables/useAgentTelemetry';
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
import SurfaceMcpEmptyStateReminder from '@/experiments/surfaceMcpToNewCloudUsers/components/SurfaceMcpEmptyStateReminder.vue';
@ -89,12 +89,9 @@ const handleReadyToRunClick = async () => {
const handleBuildAgentClick = () => {
agentTelemetry.trackClickedNewAgent('card');
void router.push({
name: NEW_AGENT_VIEW,
query: {
projectId: builderProjectId.value,
},
});
void router.push(
instanceAiCreateAgentRoute(builderProjectId.value ?? projectsStore.personalProject?.id ?? ''),
);
};
const containerStyle = computed(() => ({

View File

@ -12,7 +12,8 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
import type { CloudPlanState } from '@/Interface';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { NEW_AGENT_VIEW, AGENTS_MODULE_NAME } from '@/features/agents/constants';
import { AGENTS_MODULE_NAME } from '@/features/agents/constants';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
@ -294,7 +295,7 @@ describe('useGlobalEntityCreation', () => {
expect(ids).toEqual(['workflow', 'credential', 'agent', 'create-project']);
expect(menu.value.find((item) => item.id === 'agent')).toStrictEqual(
expect.objectContaining({
route: { name: NEW_AGENT_VIEW, query: { projectId: personalProjectId } },
route: instanceAiCreateAgentRoute(personalProjectId),
}),
);
});
@ -317,7 +318,7 @@ describe('useGlobalEntityCreation', () => {
expect(menu.value.find((item) => item.id === 'agent')).toStrictEqual(
expect.objectContaining({
disabled: false,
route: { name: NEW_AGENT_VIEW, query: { projectId: personalProjectId } },
route: instanceAiCreateAgentRoute(personalProjectId),
}),
);
});
@ -368,16 +369,13 @@ describe('useGlobalEntityCreation', () => {
expect(personal).toStrictEqual(
expect.objectContaining({
disabled: false,
route: { name: NEW_AGENT_VIEW, query: { projectId: personalProjectId } },
route: instanceAiCreateAgentRoute(personalProjectId),
}),
);
const teamWithScope = agentEntry?.submenu?.find((s) => s.id === 'agent-1');
expect(teamWithScope?.disabled).toBe(false);
expect(teamWithScope?.route).toEqual({
name: NEW_AGENT_VIEW,
query: { projectId: '1' },
});
expect(teamWithScope?.route).toEqual(instanceAiCreateAgentRoute('1'));
const teamWithoutScope = agentEntry?.submenu?.find((s) => s.id === 'agent-3');
expect(teamWithoutScope?.disabled).toBe(true);

View File

@ -1,6 +1,7 @@
import { computed, ref } from 'vue';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { AGENTS_MODULE_NAME, NEW_AGENT_VIEW } from '@/features/agents/constants';
import { AGENTS_MODULE_NAME } from '@/features/agents/constants';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
@ -239,10 +240,7 @@ export const useGlobalEntityCreation = () => {
{
id: AGENTS_MENU_ID,
title: agentTitle,
route: {
name: NEW_AGENT_VIEW,
query: { projectId: projectsStore.personalProject?.id },
},
route: instanceAiCreateAgentRoute(projectsStore.personalProject?.id ?? ''),
},
]
: []),
@ -288,10 +286,7 @@ export const useGlobalEntityCreation = () => {
id: AGENTS_MENU_ID,
title: agentTitle,
disabled: disabledAgent(projectsStore.personalProject?.scopes),
route: {
name: NEW_AGENT_VIEW,
query: { projectId: projectsStore.personalProject?.id },
},
route: instanceAiCreateAgentRoute(projectsStore.personalProject?.id ?? ''),
},
]
: []),
@ -396,20 +391,14 @@ export const useGlobalEntityCreation = () => {
title: i18n.baseText('projects.menu.personal'),
icon: 'user' as const,
disabled: disabledAgent(projectsStore.personalProject?.scopes),
route: {
name: NEW_AGENT_VIEW,
query: { projectId: projectsStore.personalProject?.id },
},
route: instanceAiCreateAgentRoute(projectsStore.personalProject?.id ?? ''),
},
...displayProjects.value.map((project) => ({
id: `agent-${project.id}`,
title: project.name as string,
icon: isProjectIcon(project.icon) ? project.icon : DEFAULT_ICON,
disabled: disabledAgent(project.scopes),
route: {
name: NEW_AGENT_VIEW,
query: { projectId: project.id },
},
route: instanceAiCreateAgentRoute(project.id),
})),
],
}),

View File

@ -32,7 +32,6 @@ export const LOCAL_STORAGE_CHAT_HUB_HAD_CONVERSATION_BEFORE = (userId: string) =
export const LOCAL_STORAGE_SIDEBAR_WIDTH = 'N8N_SIDEBAR_WIDTH';
export const LOCAL_STORAGE_BROWSER_NOTIFICATION_METADATA = 'N8N_BROWSER_NOTIFICATION_METADATA';
export const LOCAL_STORAGE_FLOATING_CHAT_WINDOW = 'N8N_FLOATING_CHAT_WINDOW';
export const LOCAL_STORAGE_AGENT_BUILDER_CHAT_PANEL_WIDTH = 'N8N_AGENT_BUILDER_CHAT_PANEL_WIDTH';
export const LOCAL_STORAGE_PARALLEL_EVAL_BY_WORKFLOW = 'N8N_PARALLEL_EVAL_BY_WORKFLOW';
export const LOCAL_STORAGE_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED =
'N8N_EVALUATIONS_CANVAS_INFO_CARD_DISMISSED';

View File

@ -1,7 +1,6 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns */
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
vi.mock('@n8n/i18n', () => ({
@ -43,7 +42,6 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c
knowledgeBaseEnabled: true,
appliedSkills: [],
connectedTriggers: [],
isBuildChatStreaming: false,
canEditAgent: false, // <<< Agent is read only
executionsDescription: '',
},
@ -142,7 +140,6 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c
knowledgeBaseEnabled: true,
appliedSkills: [],
connectedTriggers: [],
isBuildChatStreaming: false,
canEditAgent: false,
executionsDescription: '',
},
@ -185,166 +182,3 @@ describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and c
expect(wrapper.findComponent({ name: 'AgentAdvancedPanel' }).props('disabled')).toBe(true);
});
});
describe('AgentChatPanel — read-only build chat input', () => {
it('disables ChatInputBase when endpoint=build and canEditAgent=false', async () => {
vi.doMock('../composables/useAgentChatStream', () => ({
useAgentChatStream: () => ({
messages: ref([]),
isStreaming: ref(false),
messagingState: ref('idle'),
fatalError: ref(null),
loadHistory: vi.fn(),
sendMessage: vi.fn(),
stopGenerating: vi.fn(),
resume: vi.fn(),
dismissFatalError: vi.fn(),
}),
}));
vi.doMock('../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackSubmittedMessage: vi.fn() }),
}));
vi.doMock('../composables/agentTelemetry.utils', () => ({
buildAgentConfigFingerprint: vi.fn().mockResolvedValue({}),
}));
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: null,
agentStatus: 'draft',
connectedTriggers: [],
canEditAgent: false,
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
},
},
},
});
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('disabled')).toBe(true);
expect(chatInput.props('canSubmit')).toBe(false);
expect(chatInput.props('placeholder')).toBe('agents.builder.readonly.placeholder');
});
it('does not auto-send a seeded initialMessage when the build chat is read-only', async () => {
const sendMessage = vi.fn();
const loadHistory = vi.fn();
// Reset module cache so the doMock below replaces the stream mock set up
// by the earlier test in this describe block — without this, the cached
// AgentChatPanel.vue would keep using the previous mock and our
// `loadHistory` assertion would observe zero calls on the wrong fn.
vi.resetModules();
vi.doMock('../composables/useAgentChatStream', () => ({
useAgentChatStream: () => ({
messages: ref([]),
isStreaming: ref(false),
messagingState: ref('idle'),
fatalError: ref(null),
loadHistory,
sendMessage,
stopGenerating: vi.fn(),
resume: vi.fn(),
dismissFatalError: vi.fn(),
}),
}));
vi.doMock('../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackSubmittedMessage: vi.fn() }),
}));
vi.doMock('../composables/agentTelemetry.utils', () => ({
buildAgentConfigFingerprint: vi.fn().mockResolvedValue({}),
}));
const beforeSend = vi.fn();
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: null,
agentStatus: 'draft',
connectedTriggers: [],
canEditAgent: false,
initialMessage: 'seed build prompt',
beforeSend,
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div data-testid="stub-empty-state" />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
},
},
},
});
await vi.waitFor(() => {
// Setup-time auto-send has run if it was going to.
expect(wrapper.exists()).toBe(true);
});
expect(sendMessage).not.toHaveBeenCalled();
expect(beforeSend).not.toHaveBeenCalled();
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
// History is loaded instead of auto-sending — so any existing thread
// renders rather than showing a misleading "build your agent" empty state.
expect(loadHistory).toHaveBeenCalledTimes(1);
});
it('does not disable ChatInputBase for endpoint=chat (test mode) regardless of canEditAgent', async () => {
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'chat',
agentConfig: null,
agentStatus: 'production',
connectedTriggers: [],
canEditAgent: false,
},
global: {
stubs: {
N8nButton: { template: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
},
},
},
});
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('disabled')).toBe(false);
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
});
});

View File

@ -1,121 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import AgentBuilderChatColumn from '../components/AgentBuilderChatColumn.vue';
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
i18n: { baseText: (key: string) => key },
}));
function mountChatColumn({
isBuildChatStreaming,
chatActionsDisabled = false,
}: {
isBuildChatStreaming: boolean;
chatActionsDisabled?: boolean;
}) {
return mount(AgentBuilderChatColumn, {
props: {
initialized: true,
projectId: 'p1',
agentId: 'a1',
agentName: 'Agent One',
agent: null,
localConfig: null,
connectedTriggers: [],
isBuilderConfigured: true,
isFullWidth: false,
canEditAgent: true,
isBuildChatStreaming,
},
global: {
stubs: {
N8nButton: {
template: '<button @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
emits: ['click'],
},
N8nIcon: {
template: '<span :data-icon="icon" />',
props: ['icon'],
},
N8nTooltip: { template: '<span><slot /></span>' },
AgentBuilderUnconfiguredEmptyState: { template: '<div />' },
AgentChatPanel: {
name: 'AgentChatPanel',
template: '<div><slot name="above-input" :disabled="chatActionsDisabled" /></div>',
props: [
'inputDraft',
'projectId',
'agentId',
'mode',
'endpoint',
'initialMessage',
'agentConfig',
'agentStatus',
'connectedTriggers',
'canEditAgent',
'beforeSend',
'chatActionsDisabled',
],
data: () => ({ chatActionsDisabled }),
},
AgentChatQuickActions: {
name: 'AgentChatQuickActions',
template: '<div data-testid="stub-agent-chat-quick-actions" />',
props: [
'tools',
'mcpServers',
'projectId',
'agentId',
'connectedTriggers',
'isPublished',
'disabled',
],
},
},
},
});
}
describe('AgentBuilderChatColumn', () => {
it('emits hide when the floating hide button is clicked', async () => {
const wrapper = mountChatColumn({ isBuildChatStreaming: false });
await wrapper.find('[data-testid="agent-build-chat-hide-toggle"]').trigger('click');
expect(wrapper.emitted('hide')).toEqual([[]]);
});
it('uses a close-panel arrow icon for the floating hide button', () => {
const wrapper = mountChatColumn({ isBuildChatStreaming: false });
expect(
wrapper
.find('[data-testid="agent-build-chat-hide-toggle"] [data-icon="panel-left-close"]')
.exists(),
).toBe(true);
});
it('disables quick actions while the build chat is streaming', () => {
const wrapper = mountChatColumn({ isBuildChatStreaming: true });
expect(wrapper.findComponent({ name: 'AgentChatQuickActions' }).props('disabled')).toBe(true);
});
it('disables quick actions while the chat panel has an open HITL interaction', () => {
const wrapper = mountChatColumn({
isBuildChatStreaming: false,
chatActionsDisabled: true,
});
expect(wrapper.findComponent({ name: 'AgentChatQuickActions' }).props('disabled')).toBe(true);
});
it('keeps quick actions enabled while the build chat is idle', () => {
const wrapper = mountChatColumn({ isBuildChatStreaming: false });
expect(wrapper.findComponent({ name: 'AgentChatQuickActions' }).props('disabled')).toBe(false);
});
});

View File

@ -153,7 +153,6 @@ async function mountColumn(
knowledgeBaseEnabled: overrides.knowledgeBaseEnabled ?? true,
appliedSkills: [],
connectedTriggers: [],
isBuildChatStreaming: false,
canEditAgent: true,
executionsDescription: '',
},

View File

@ -4,6 +4,7 @@ import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { ref } from 'vue';
import type { AgentResource } from '../types';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
const ensureLoadedMock = vi.fn();
const agentsListRef = ref<AgentResource[] | null>(null);
@ -315,14 +316,11 @@ describe('AgentBuilderHeader', () => {
expect(wrapper.emitted('switch-agent')).toEqual([['a2']]);
});
it('navigates to the new agent page from the switcher footer', async () => {
it('navigates to Instance AI for agent creation from the switcher footer', async () => {
const wrapper = mountHeader();
await wrapper.find('[data-testid="agent-header-new-agent"]').trigger('click');
expect(routerPush).toHaveBeenCalledWith({
name: 'NewAgentView',
query: { projectId: 'p1' },
});
expect(routerPush).toHaveBeenCalledWith(instanceAiCreateAgentRoute('p1'));
});
});

View File

@ -1,151 +0,0 @@
import { waitFor } from '@testing-library/vue';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { AgentSseEvent } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import AgentBuilderProgress from '../components/AgentBuilderProgress.vue';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
}));
vi.mock('@n8n/i18n', () => {
const i18n = { baseText: (key: string) => key };
return { useI18n: () => i18n, i18n, i18nInstance: { install: vi.fn() } };
});
/** Build a `Response` whose body streams the given events as SSE `data:` lines. */
function makeSseResponse(events: AgentSseEvent[]): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const ev of events) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}
/** Build a `Response` whose SSE body is fed manually via the returned `push`/`close`. */
function makeControlledSseResponse() {
const encoder = new TextEncoder();
let controller!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
}),
push(ev: AgentSseEvent) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`));
},
close() {
controller.close();
},
};
}
/** Each `tool-call` event pushes exactly one `→ Tool N` line into the log. */
function toolCallEvents(count: number): AgentSseEvent[] {
return Array.from({ length: count }, (_, i) => ({
type: 'tool-call' as const,
toolCallId: `tc-${i}`,
toolName: `tool_${i}`,
input: {},
}));
}
const renderComponent = createComponentRenderer(AgentBuilderProgress, {
props: { projectId: 'p1', agentId: 'a1', initialMessage: 'build me an agent' },
});
const getLogBox = (container: Element) => container.querySelector('[aria-live="polite"]');
describe('AgentBuilderProgress — log box top fade', () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('keeps the first log line fully visible (no top fade) while no lines have been dropped', async () => {
// 7 lines = MAX_LINES exactly — at capacity but nothing dropped yet
globalThis.fetch = vi.fn(async () =>
makeSseResponse([...toolCallEvents(7), { type: 'done' }]),
) as typeof fetch;
const { container, emitted } = renderComponent();
await waitFor(() => expect(emitted()).toHaveProperty('done'));
const logBox = getLogBox(container);
expect(logBox).not.toBeNull();
expect(logBox?.querySelectorAll('.line')).toHaveLength(7);
expect(logBox?.className).toContain('logBox');
expect(logBox?.className).not.toContain('logBoxFaded');
});
it('applies the top fade once older lines are dropped from the log', async () => {
// 9 lines > MAX_LINES — the two oldest get dropped
globalThis.fetch = vi.fn(async () =>
makeSseResponse([...toolCallEvents(9), { type: 'done' }]),
) as typeof fetch;
const { container, emitted } = renderComponent();
await waitFor(() => expect(emitted()).toHaveProperty('done'));
const logBox = getLogBox(container);
expect(logBox).not.toBeNull();
await waitFor(() => expect(logBox?.querySelectorAll('.line')).toHaveLength(7));
// Oldest surviving line is tool_2 — tool_0 and tool_1 were dropped
expect(logBox?.textContent).not.toContain('Tool 0');
expect(logBox?.textContent).toContain('Tool 2');
expect(logBox?.className).toContain('logBoxFaded');
});
it('keeps DOM nodes of surviving lines stable when the log window slides', async () => {
// Guards the TransitionGroup keys: with unstable keys every trim
// recreates all rows, so they leave + re-enter and briefly stack on top
// of each other (position: absolute during leave).
const sse = makeControlledSseResponse();
globalThis.fetch = vi.fn(async () => sse.response) as typeof fetch;
const { container } = renderComponent();
for (const ev of toolCallEvents(7)) sse.push(ev);
const logBox = getLogBox(container);
await waitFor(() => expect(logBox?.querySelectorAll('.line')).toHaveLength(7));
const survivor = [...(logBox?.querySelectorAll('.line') ?? [])].find((el) =>
el.textContent?.includes('Tool 6'),
);
expect(survivor).toBeDefined();
// Two more lines slide the window: tool_0 and tool_1 get dropped
sse.push({ type: 'tool-call', toolCallId: 'tc-7', toolName: 'tool_7', input: {} });
sse.push({ type: 'tool-call', toolCallId: 'tc-8', toolName: 'tool_8', input: {} });
sse.push({ type: 'done' });
sse.close();
await waitFor(() => expect(logBox?.textContent).toContain('Tool 8'));
await waitFor(() => expect(logBox?.textContent).not.toContain('Tool 0'));
const survivorAfter = [...(logBox?.querySelectorAll('.line') ?? [])].find((el) =>
el.textContent?.includes('Tool 6'),
);
expect(survivorAfter).toBe(survivor);
});
});

View File

@ -1,7 +1,7 @@
/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep and private-state reads */
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { nextTick, ref } from 'vue';
import { nextTick, ref, computed } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { MAX_AGENT_KNOWLEDGE_BASE_SIZE_BYTES } from '@n8n/api-types';
import type {
@ -148,13 +148,6 @@ vi.mock('../composables/useAgentBuilderTelemetry', () => ({
}),
}));
vi.mock('../composables/useAgentBuilderStatus', () => ({
useAgentBuilderStatus: () => ({
isBuilderConfigured: ref(true),
fetchStatus: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('../composables/useAgentPermissions', () => ({
useAgentPermissions: () => agentPermissionsMock,
}));
@ -273,13 +266,18 @@ vi.mock('../composables/useProjectAgentsList', () => ({
}),
}));
const instanceAiAvailableRef = ref(true);
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiAvailability', () => ({
useInstanceAiAvailable: () => computed(() => instanceAiAvailableRef.value),
}));
const startInstanceAiThread = vi.fn();
vi.mock('@/features/ai/instanceAi/composables/useInstanceAiHandoff', () => ({
useInstanceAiHandoff: () => ({ startThread: startInstanceAiThread }),
}));
const baseTextFn = (key: string) => {
const map: Record<string, string> = {
'agents.builder.chatMode.build': 'Build',
'agents.builder.chatMode.test': 'Test',
'agents.builder.chatMode.ariaLabel': 'Switch chat mode',
'agents.builder.chat.hide.ariaLabel': 'Hide builder',
'agents.builder.chat.show.ariaLabel': 'Show builder',
'agents.builder.preview.button': 'Preview',
'agents.builder.preview.close.ariaLabel': 'Close preview',
'projects.menu.personal': 'Personal',
@ -344,17 +342,15 @@ const commonStubs = {
AgentChatPanel: {
name: 'AgentChatPanel',
template: `
<div data-testid="chat-panel-stub" :data-endpoint="endpoint">
<div data-testid="chat-panel-stub">
<div data-testid="stub-above-input"><slot name="above-input" /></div>
<div data-testid="stub-footer-start"><slot name="footer-start" /></div>
</div>
`,
props: [
'endpoint',
'projectId',
'agentId',
'mode',
'initialMessage',
'agentConfig',
'agentStatus',
'connectedTriggers',
@ -372,9 +368,7 @@ const commonStubs = {
'localConfig',
'connectedTriggers',
'effectiveSessionId',
'initialPrompt',
],
emits: ['config-updated', 'continue-loaded', 'open-build'],
},
AgentConfigTree: {
name: 'AgentConfigTree',
@ -388,20 +382,6 @@ const commonStubs = {
props: ['config'],
emits: ['update:config'],
},
AgentChatQuickActions: {
name: 'AgentChatQuickActions',
template: '<div data-testid="stub-agent-chat-quick-actions" />',
props: [
'tools',
'mcpServers',
'projectId',
'agentId',
'connectedTriggers',
'isPublished',
'disabled',
],
emits: ['update:tools', 'update:connected-triggers', 'trigger-added'],
},
AgentBuilderHeader: {
name: 'AgentBuilderHeader',
template:
@ -481,15 +461,16 @@ const commonStubs = {
template: '<div data-testid="stub-agent-sessions-list-view" />',
props: ['embedded', 'projectId', 'agentId', 'openSessionInNewTab'],
},
AgentBuilderUnconfiguredEmptyState: {
name: 'AgentBuilderUnconfiguredEmptyState',
template: '<div data-testid="stub-agent-builder-unconfigured-empty-state" />',
},
N8nButton: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
emits: ['click'],
},
N8nAssistantIcon: { template: '<i data-testid="stub-assistant-icon" />', props: ['size'] },
N8nTooltip: {
template: '<span data-testid="stub-tooltip"><slot /></span>',
props: ['placement', 'content'],
},
N8nIcon: {
template: '<i v-bind="$attrs" :data-icon="icon"></i>',
props: ['icon', 'size', 'spin'],
@ -550,19 +531,16 @@ describe('AgentBuilderView — preview routing', () => {
favoritesStoreMock.toggleFavorite.mockClear();
favoritesStoreMock.renameFavorite.mockClear();
favoritesStoreMock.removeFavoriteLocally.mockClear();
instanceAiAvailableRef.value = true;
startInstanceAiThread.mockReset();
});
it('renders the build chat in the editing experience without the old mode toggle', async () => {
it('renders the manual editor without an agents-page build chat', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-build-chat-show-button"]').trigger('click');
await nextTick();
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false);
});
@ -731,15 +709,16 @@ describe('AgentBuilderView — preview routing', () => {
);
});
it('keeps unbuilt agents hidden on load until the builder chat is opened', async () => {
it('shows the manual editor for unbuilt agents', async () => {
intendedConfig = { name: 'Agent One', instructions: '' };
mockConfig.value = withDefaultLlm(intendedConfig);
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
const wrapper = await renderView();
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(false);
});
it('opens the preview route from the header preview action', async () => {
@ -810,30 +789,17 @@ describe('AgentBuilderView — preview routing', () => {
expect(warmAgentKnowledgeSandboxMock).toHaveBeenCalledTimes(1);
});
it('navigates directly to build chat on startChat for an unbuilt agent', async () => {
intendedConfig = { name: 'Agent One', instructions: '' };
mockConfig.value = withDefaultLlm(intendedConfig);
getAgentMock.mockResolvedValue(makeAgentResponse({ isRunnable: false }));
it('clears stale prompt query params without opening a build chat', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
const vm = wrapper.vm as unknown as {
startChat: (msg: string) => void;
isBuilt: boolean;
};
// Agent has no instructions — isBuilt should be false.
expect(vm.isBuilt).toBe(false);
expect(routerReplace).toHaveBeenCalledWith({
query: { prompt: undefined, expandBuildChat: undefined },
});
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
vm.startChat('Build me a Slack triage agent');
await nextTick();
// No progress screen rendered
expect(wrapper.find('[data-testid="progress-stub"]').exists()).toBe(false);
// Build chat panel should be visible
const buildPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]');
expect(buildPanel.exists()).toBe(true);
});
it('refreshes runnable state from the backend after saving manual config edits', async () => {
@ -915,147 +881,29 @@ describe('AgentBuilderView — three-column shell', () => {
favoritesStoreMock.toggleFavorite.mockClear();
favoritesStoreMock.renameFavorite.mockClear();
favoritesStoreMock.removeFavoriteLocally.mockClear();
instanceAiAvailableRef.value = true;
startInstanceAiThread.mockReset();
});
it('hides the build chat by default while keeping the editor visible', async () => {
it('renders only the manual editor without build chat controls', async () => {
const wrapper = await renderView();
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(true);
});
it('restores and hides the build chat from the floating controls', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-build-chat-show-button"]').trigger('click');
await nextTick();
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(false);
const chatColumn = wrapper.findComponent({ name: 'AgentBuilderChatColumn' });
chatColumn.vm.$emit('hide');
await nextTick();
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(true);
});
it('renders a floating hide control in build chat mode', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-build-chat-show-button"]').trigger('click');
await nextTick();
expect(wrapper.find('[data-testid="agent-build-chat-hide-toggle"]').exists()).toBe(true);
});
it('renders seeded initial builds from the URL as full-width once initialization settles', async () => {
it('clears stale prompt query params from old deep links', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
await renderView();
const chatColumn = wrapper.findComponent({ name: 'AgentBuilderChatColumn' });
expect(chatColumn.props('isFullWidth')).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(false);
});
it('auto-expands seeded initial builds from the URL and clears the query flag', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
const chatColumn = wrapper.findComponent({ name: 'AgentBuilderChatColumn' });
expect(chatColumn.props('isFullWidth')).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(false);
expect(routerReplace).toHaveBeenCalledWith({
query: { prompt: undefined, expandBuildChat: undefined },
});
});
it('keeps an auto-expanded initial build open on config updates before build completion', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
wrapper.findComponent({ name: 'AgentBuilderChatColumn' }).vm.$emit('config-updated');
await flushPromises();
expect(wrapper.findComponent({ name: 'AgentBuilderChatColumn' }).props('isFullWidth')).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(false);
});
it('collapses an auto-expanded initial build when the build finishes with written config', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
wrapper.findComponent({ name: 'AgentBuilderChatColumn' }).vm.$emit('build-done');
await flushPromises();
expect(wrapper.findComponent({ name: 'AgentBuilderChatColumn' }).props('isFullWidth')).toBe(
false,
);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
});
it('mounts the editor enabled when the initial build completion collapses the chat', async () => {
routeQuery.prompt = 'Build a recruiting agent';
routeQuery.expandBuildChat = 'true';
const wrapper = await renderView();
const chatColumn = wrapper.findComponent({ name: 'AgentBuilderChatColumn' });
chatColumn.vm.$emit('update:streaming', true);
chatColumn.vm.$emit('build-done');
await flushPromises();
expect(
wrapper.findComponent({ name: 'AgentBuilderEditorColumn' }).props('isBuildChatStreaming'),
).toBe(false);
});
it('passes build streaming state to the chat column', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-build-chat-show-button"]').trigger('click');
await nextTick();
const chatColumn = wrapper.findComponent({ name: 'AgentBuilderChatColumn' });
chatColumn.vm.$emit('update:streaming', true);
await nextTick();
expect(
wrapper.findComponent({ name: 'AgentBuilderChatColumn' }).props('isBuildChatStreaming'),
).toBe(true);
});
it('does not render the old Build/Test toggle inside the chat input footer', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-build-chat-show-button"]').trigger('click');
await nextTick();
const chatPanel = wrapper.find('[data-testid="chat-panel-stub"][data-endpoint="build"]');
expect(
chatPanel
.find('[data-testid="stub-footer-start"] [data-testid="agent-chat-mode-toggle"]')
.exists(),
).toBe(false);
expect(
chatPanel
.find('[data-testid="stub-above-input"] [data-testid="agent-chat-mode-toggle"]')
.exists(),
).toBe(false);
});
it('does not render the old home content or settings sidebar', async () => {
const wrapper = await renderView();
const html = wrapper.html();
@ -1068,6 +916,45 @@ describe('AgentBuilderView — three-column shell', () => {
expect(wrapper.find('[data-testid="stub-agent-builder-header"]').exists()).toBe(true);
});
it('renders the floating Instance AI button in builder mode', async () => {
const wrapper = await renderView();
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(true);
});
it('hides the floating Instance AI button in artifact mode', async () => {
const wrapper = await renderView({
props: {
artifactMode: true,
artifactProjectId: 'p2',
artifactAgentId: 'a2',
artifactRefreshKey: 0,
},
});
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
});
it('hides the floating Instance AI button when Instance AI is unavailable', async () => {
instanceAiAvailableRef.value = false;
const wrapper = await renderView();
expect(wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').exists()).toBe(false);
});
it('starts an Instance AI thread with the agent attached on click', async () => {
const wrapper = await renderView();
await wrapper.find('[data-testid="agent-builder-instance-ai-btn"]').trigger('click');
await flushPromises();
expect(startInstanceAiThread).toHaveBeenCalledWith('p1', '', [
{
type: 'agent',
id: 'a1',
name: 'Agent One',
projectId: 'p1',
},
]);
});
it('renders artifact mode with the editor and without the build chat', async () => {
const wrapper = await renderView({
props: {
@ -1715,8 +1602,7 @@ describe('AgentBuilderView — three-column shell', () => {
// Spinner gone, content rendered.
expect(wrapper.find('[data-icon="spinner"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-builder-chat-column"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-build-chat-show-button"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-builder-editor-column"]').exists()).toBe(true);
});
it('clears the loading spinner and shows an error when initialize() throws (finally path)', async () => {

View File

@ -39,7 +39,7 @@ vi.mock('@/features/agents/components/interactive/InteractiveCard.vue', () => ({
default: {
template:
'<div data-testid="interactive-card-stub" :data-tool-call-id="payload.toolCallId" :data-run-id="payload.runId || \'\'" />',
props: ['payload', 'projectId', 'agentId'],
props: ['payload'],
},
}));
@ -361,40 +361,6 @@ describe('AgentChatMessageList', () => {
expect(cards[0].attributes('data-run-id')).toBe('run-active');
});
it('collapses resolved builder cards into the tool-step summary (no card)', () => {
const wrapper = mount(AgentChatMessageList, {
props: {
messages: [
{
id: 'assistant-resolved-question',
role: 'assistant',
content: 'Thanks!',
interactive: {
toolName: 'ask_questions',
toolCallId: 'tc-q',
resolvedAt: 1,
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,
],
messagingState: 'idle',
},
});
expect(wrapper.find('[data-testid="interactive-card-stub"]').exists()).toBe(false);
});
it('does not render external-wait notice for suspended chat_action tool (toolRun path)', () => {
// isGroupable: role=assistant, toolCalls.length>0, content is empty → toolRun group
const wrapper = mount(AgentChatMessageList, {

View File

@ -1,11 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import { computed, h, ref } from 'vue';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
type InteractiveToolName,
} from '@n8n/api-types';
import { APPROVAL_TOOL_NAME, N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
import AgentChatPanel from '../components/AgentChatPanel.vue';
@ -37,7 +33,7 @@ vi.mock('@/features/ai/shared/components/ChatInputBase.vue', () => ({
}));
vi.mock('../components/AgentChatEmptyState.vue', () => ({
default: { template: '<div data-testid="empty-state-stub" />', props: ['endpoint'] },
default: { template: '<div data-testid="empty-state-stub" />' },
}));
vi.mock('../components/AgentChatMessageList.vue', () => ({
@ -87,7 +83,6 @@ describe('AgentChatPanel', () => {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
@ -99,50 +94,45 @@ describe('AgentChatPanel', () => {
});
}
function openInteractiveMessage(
toolName: InteractiveToolName = ASK_QUESTIONS_TOOL_NAME,
): ChatMessage {
/**
* A non-approval interactive card (`chat_action`) these put the chat
* input into cancel-and-steer mode rather than blocking it outright,
* unlike an open approval card.
*/
function openInteractiveMessage(): ChatMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: '',
status: 'awaitingUser',
interactive:
toolName === ASK_QUESTIONS_TOOL_NAME
? {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: {
requestId: 'req-1',
message: 'Pick one',
severity: 'info',
inputType: 'questions',
questions: [{ id: 'q1', question: 'Pick one', type: 'single', options: ['slack'] }],
},
}
: {
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' },
},
},
interactive: {
toolName: N8N_CHAT_ACTION_TOOL_NAME,
toolCallId: 'tc-1',
runId: 'run-1',
input: {
card: { components: [{ type: 'button', label: 'Pick Slack', value: 'slack' }] },
},
},
};
}
it('awaits beforeSend before sending a build message', async () => {
function resolvedInteractiveMessage(): ChatMessage {
return {
...openInteractiveMessage(),
status: 'success',
interactive: {
toolName: N8N_CHAT_ACTION_TOOL_NAME,
toolCallId: 'tc-1',
resolvedAt: 1,
input: {
card: { components: [{ type: 'button', label: 'Pick Slack', value: 'slack' }] },
},
resolvedValue: { type: 'button', value: 'slack' },
},
};
}
it('awaits beforeSend before sending a chat message', async () => {
const events: string[] = [];
let resolveBeforeSend: () => void = () => {};
const beforeSend = vi.fn(
@ -162,7 +152,6 @@ describe('AgentChatPanel', () => {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
@ -189,64 +178,6 @@ describe('AgentChatPanel', () => {
expect(events).toEqual(['beforeSend', 'sendMessage']);
});
it('does not consume an initial message when beforeSend fails', async () => {
const beforeSend = vi.fn().mockRejectedValue(new Error('flush failed'));
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
initialMessage: 'seed build prompt',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Help.',
},
agentStatus: 'draft',
connectedTriggers: [],
beforeSend,
},
});
await flushPromises();
expect(beforeSend).toHaveBeenCalledTimes(1);
expect(sendMessageMock).not.toHaveBeenCalled();
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
});
it('does not consume a seeded initial message in a read-only build chat', async () => {
const beforeSend = vi.fn();
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
canEditAgent: false,
initialMessage: 'seed build prompt',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Help.',
},
agentStatus: 'draft',
connectedTriggers: [],
beforeSend,
},
});
await flushPromises();
expect(beforeSend).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
// History is loaded instead so any existing thread renders, rather than
// the misleading "describe your agent" empty state.
expect(loadHistoryMock).toHaveBeenCalledTimes(1);
});
it('enables chat input and shows answer-question placeholder while an interactive question is unresolved', () => {
messagesMock.value = [openInteractiveMessage()];
@ -265,7 +196,6 @@ describe('AgentChatPanel', () => {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
@ -289,34 +219,12 @@ describe('AgentChatPanel', () => {
});
it('keeps above-input actions enabled when the interactive card is resolved', () => {
messagesMock.value = [
{
...openInteractiveMessage(),
status: 'success',
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'tc-1',
resolvedAt: 1,
input: {
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'] }],
},
},
},
];
messagesMock.value = [resolvedInteractiveMessage()];
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'build',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
@ -359,20 +267,11 @@ describe('AgentChatPanel', () => {
...openInteractiveMessage(),
status: 'success',
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'tc-1',
resolvedAt: 1,
input: {
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'] }],
},
input: { type: 'approval', toolName: 'send_message', args: {} },
resolvedValue: { approved: true },
},
},
];
@ -384,41 +283,18 @@ describe('AgentChatPanel', () => {
expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
});
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)];
it('enables chat input while an interactive card is unresolved (cancel-and-steer mode)', () => {
messagesMock.value = [openInteractiveMessage()];
const wrapper = mountPanel();
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
// Input should be enabled — the user can cancel and steer
expect(chatInput.props('disabled')).toBe(false);
},
);
it('lifts the character limit for the build endpoint', () => {
const wrapper = mountPanel();
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('maxLength')).toBe(25_000);
// Input should be enabled — the user can cancel and steer
expect(chatInput.props('disabled')).toBe(false);
});
it('keeps the default character limit for the chat endpoint', () => {
const wrapper = mount(AgentChatPanel, {
props: {
projectId: 'p1',
agentId: 'a1',
endpoint: 'chat',
agentConfig: {
name: 'Agent',
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Help.',
},
agentStatus: 'draft',
connectedTriggers: [],
},
});
it('does not apply a build-specific character limit', () => {
const wrapper = mountPanel();
const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
expect(chatInput.props('maxLength')).toBe(undefined);

View File

@ -1,180 +0,0 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import AgentChatQuickActions from '../components/AgentChatQuickActions.vue';
import type { AgentJsonToolRef } from '../types';
const openModalWithData = vi.fn();
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ openModalWithData }),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (k: string) => k }),
}));
const globalStubs = {
N8nButton: {
props: ['type', 'size', 'disabled'],
template:
'<button v-bind="$attrs" :disabled="disabled !== false && disabled !== undefined ? true : undefined" @click="$emit(\'click\', $event)"><slot name="prefix" /><slot /></button>',
emits: ['click'],
inheritAttrs: false,
},
N8nIcon: { template: '<i></i>' },
AgentChannelModal: {
name: 'AgentChannelModal',
props: ['open', 'view', 'agentId', 'projectId', 'connectedChannels'],
template: '<div data-testid="agent-channel-modal" />',
emits: [
'update:open',
'update:view',
'channel-connected',
'channel-disconnected',
'agent-changed',
],
},
};
const defaultProps = {
tools: [] as AgentJsonToolRef[],
projectId: 'p1',
agentId: 'a1',
connectedTriggers: [] as string[],
isPublished: false,
};
describe('AgentChatQuickActions', () => {
beforeEach(() => openModalWithData.mockClear());
it('renders Add tool and Add trigger chips', () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
expect(wrapper.find('[data-testid="agent-quick-action-add-tool"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-quick-action-add-trigger"]').exists()).toBe(true);
});
it('does not render Run now or Edit config chips', () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
expect(wrapper.find('[data-testid="agent-quick-action-run"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-quick-action-edit"]').exists()).toBe(false);
});
it('Add tool is enabled', () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
expect(
wrapper.find('[data-testid="agent-quick-action-add-tool"]').attributes('disabled'),
).toBeUndefined();
});
it('Add trigger is enabled', () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
expect(
wrapper.find('[data-testid="agent-quick-action-add-trigger"]').attributes('disabled'),
).toBeUndefined();
});
it('disables add actions and does not open modals when disabled', async () => {
const wrapper = mount(AgentChatQuickActions, {
props: { ...defaultProps, disabled: true },
global: { stubs: globalStubs },
});
const addTool = wrapper.find('[data-testid="agent-quick-action-add-tool"]');
const addTrigger = wrapper.find('[data-testid="agent-quick-action-add-trigger"]');
expect(addTool.attributes('disabled')).toBeDefined();
expect(addTrigger.attributes('disabled')).toBeDefined();
await addTool.trigger('click');
await addTrigger.trigger('click');
expect(openModalWithData).not.toHaveBeenCalled();
expect(wrapper.findComponent({ name: 'AgentChannelModal' }).exists()).toBe(false);
});
it('Add tool opens the AgentToolsModal with current tools and ids', async () => {
const tools = [{ type: 'node', name: 'x' } as unknown as AgentJsonToolRef];
const wrapper = mount(AgentChatQuickActions, {
props: { ...defaultProps, tools },
global: { stubs: globalStubs },
});
await wrapper.find('[data-testid="agent-quick-action-add-tool"]').trigger('click');
expect(openModalWithData).toHaveBeenCalledTimes(1);
const call = openModalWithData.mock.calls[0][0];
expect(call.name).toBe('agentToolsModal');
expect(call.data.tools).toEqual(tools);
expect(call.data.projectId).toBe('p1');
expect(call.data.agentId).toBe('a1');
expect(typeof call.data.onConfirm).toBe('function');
});
it('emits update:tools when the tools modal confirms a selection', async () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
await wrapper.find('[data-testid="agent-quick-action-add-tool"]').trigger('click');
const { onConfirm } = openModalWithData.mock.calls[0][0].data;
const next = [{ type: 'node', name: 'y' } as unknown as AgentJsonToolRef];
onConfirm({ tools: next });
expect(wrapper.emitted('update:tools')?.[0]).toEqual([next]);
});
it('Add trigger opens the AgentChannelModal with correct data', async () => {
const connectedTriggers = ['slack'];
const wrapper = mount(AgentChatQuickActions, {
props: { ...defaultProps, connectedTriggers },
global: { stubs: globalStubs },
});
await wrapper.find('[data-testid="agent-quick-action-add-trigger"]').trigger('click');
const modal = wrapper.findComponent({ name: 'AgentChannelModal' });
expect(modal.exists()).toBe(true);
expect(modal.props('open')).toBe(true);
expect(modal.props('view')).toBe('list');
expect(modal.props('projectId')).toBe('p1');
expect(modal.props('agentId')).toBe('a1');
expect(modal.props('connectedChannels')).toEqual(connectedTriggers);
});
it('emits update:connected-triggers when a channel is connected', async () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
await wrapper.find('[data-testid="agent-quick-action-add-trigger"]').trigger('click');
await wrapper
.findComponent({ name: 'AgentChannelModal' })
.vm.$emit('channel-connected', 'telegram');
expect(wrapper.emitted('update:connected-triggers')?.[0]).toEqual([['telegram']]);
});
it('emits trigger-added when a channel is connected', async () => {
const wrapper = mount(AgentChatQuickActions, {
props: defaultProps,
global: { stubs: globalStubs },
});
await wrapper.find('[data-testid="agent-quick-action-add-trigger"]').trigger('click');
await wrapper
.findComponent({ name: 'AgentChannelModal' })
.vm.$emit('channel-connected', 'slack');
expect(wrapper.emitted('trigger-added')?.[0]).toEqual([
{ triggerType: 'slack', triggers: ['slack'] },
]);
});
});

View File

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentResource } from '../types';
import AgentsListView from '../views/AgentsListView.vue';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
const mocks = vi.hoisted(() => ({
listAgentsPage: vi.fn(),
@ -298,3 +299,21 @@ describe('AgentsListView — overview page', () => {
expect(mocks.listAgentsPage).not.toHaveBeenCalled();
});
});
describe('AgentsListView — create agent', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.routeProjectId = 'project-1';
});
it('routes create-agent clicks to Instance AI with the project context', async () => {
mocks.listAgentsPage.mockResolvedValueOnce({ count: 0, data: [] });
const wrapper = await mountView();
const vm = wrapper.vm as unknown as { onCreateAgentClick: () => void };
vm.onCreateAgentClick();
expect(mocks.trackClickedNewAgent).toHaveBeenCalledWith('button');
expect(mocks.routerPush).toHaveBeenCalledWith(instanceAiCreateAgentRoute('project-1'));
});
});

View File

@ -1,205 +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 { inject } from 'vue';
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,
}),
}));
vi.mock('@n8n/i18n', () => {
const baseText = (k: string, opts?: { interpolate?: Record<string, string> }) => {
if (opts?.interpolate) return `${k}:${Object.values(opts.interpolate).join(',')}`;
return k;
};
return {
useI18n: () => ({ baseText }),
i18n: { baseText },
};
});
/**
* Stub NodeCredentials so the test can drive the credentialSelected event
* without booting the full component graph (which pulls in stores, the
* AI gateway, ndv event bus, etc.). The stub exposes the key inputs as
* data attributes so we can assert prop wiring, plus a "pick" button to
* simulate the user choosing a credential and a "clear" button to simulate
* deselection.
*/
const NodeCredentialsStub = {
props: {
node: { type: Object, required: true },
overrideCredType: { type: String, default: '' },
projectId: { type: String, default: '' },
standalone: { type: Boolean, default: false },
hideIssues: { type: Boolean, default: false },
skipAutoSelect: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
},
setup() {
return {
isToolContext: inject(ChatHubToolContextKey, false),
};
},
emits: ['credentialSelected'],
template: `
<div
data-testid="node-credentials-stub"
:data-override-cred-type="overrideCredType"
:data-project-id="projectId"
:data-standalone="String(standalone)"
:data-hide-issues="String(hideIssues)"
:data-tool-context="String(isToolContext)"
:data-skip-auto-select="String(skipAutoSelect)"
:data-readonly="String(readonly)"
:data-node-type="node?.type"
>
<button
data-testid="stub-pick-credential"
@click="$emit('credentialSelected', {
name: node.name,
properties: { credentials: { [overrideCredType]: { id: 'cred-1', name: 'Acme Slack' } } },
})"
>pick</button>
<button
data-testid="stub-clear-credential"
@click="$emit('credentialSelected', {
name: node.name,
properties: { credentials: {} },
})"
>clear</button>
</div>
`,
};
const CredentialIconStub = {
template: '<i data-testid="credential-icon" />',
props: ['credentialTypeName', 'size'],
};
import AskCredentialCard from '../components/interactive/AskCredentialCard.vue';
const baseProps = {
credentialRequests: [
{ credentialType: 'slackApi', reason: 'Slack credential', existingCredentials: [] },
],
message: 'Slack credential',
projectId: 'p1',
};
function mountCard(props: Record<string, unknown> = {}) {
return mount(AskCredentialCard, {
props: { ...baseProps, ...props },
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'] },
},
},
});
}
beforeEach(() => {
vi.clearAllMocks();
for (const id of Object.keys(credentialsById)) delete credentialsById[id];
credentialsById['cred-1'] = { id: 'cred-1', name: 'Acme Slack' };
credentialsById['cred-9'] = { id: 'cred-9', name: 'Picked Slack' };
});
describe('AskCredentialCard', () => {
it('renders the NodeCredentials picker with the correct override type, project and standalone flags', async () => {
const wrapper = mountCard();
await flushPromises();
const stub = wrapper.find('[data-testid="node-credentials-stub"]');
expect(stub.exists()).toBe(true);
expect(stub.attributes('data-override-cred-type')).toBe('slackApi');
expect(stub.attributes('data-project-id')).toBe('p1');
expect(stub.attributes('data-standalone')).toBe('true');
expect(stub.attributes('data-hide-issues')).toBe('true');
expect(stub.attributes('data-tool-context')).toBe('true');
expect(stub.attributes('data-skip-auto-select')).toBe('true');
expect(stub.attributes('data-readonly')).toBe('false');
});
it('emits { skipped: true } when Skip is pressed', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="ask-credential-skip"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted).toBeTruthy();
expect(emitted[0][0]).toEqual({ skipped: true });
});
it('emits the chosen credential as a `credentials` map as soon as it is picked', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="stub-pick-credential"]').trigger('click');
const emitted = wrapper.emitted('submit') as unknown[][];
expect(emitted[0][0]).toEqual({ credentials: { slackApi: 'cred-1' } });
});
it('does not emit when NodeCredentials emits an empty credentials map', async () => {
const wrapper = mountCard();
await flushPromises();
await wrapper.find('[data-testid="stub-clear-credential"]').trigger('click');
expect(wrapper.emitted('submit')).toBeFalsy();
});
it('does not render the skip button when disabled', async () => {
const wrapper = mountCard({ disabled: true });
await flushPromises();
expect(wrapper.find('[data-testid="ask-credential-skip"]').exists()).toBe(false);
});
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('agents.chat.askCredential.skipped');
});
});

View File

@ -1,99 +0,0 @@
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();
});
});

View File

@ -1,102 +0,0 @@
/* 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');
});
});

View File

@ -1,46 +1,11 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { mount } from '@vue/test-utils';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
} from '@n8n/api-types';
import { APPROVAL_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> }) => {
@ -144,108 +109,4 @@ 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);
});
});

View File

@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import source from '../views/NewAgentView.vue?raw';
describe('NewAgentView', () => {
it('tells the builder to attach generated skill refs through config patching', () => {
expect(source).not.toContain('then register the returned id in the agent config skills array');
expect(source).toContain(
'For each skill, call create_skill with the name, description, and body.',
);
expect(source).toContain(
'After creating the skills, call read_config and patch_config to append the returned skill refs to the config skills array.',
);
});
it('tracks the description-prompt build as a build-mode submission', () => {
// Without this event, the most interesting prompt of an agent's life
// (the one that creates and configures it) goes unmeasured. The build
// chat path already fires `User submitted message to agent`; this check
// guards against the NewAgentView shortcut silently bypassing it.
expect(source).toContain('agentTelemetry.trackSubmittedMessage');
expect(source).toContain("mode: 'build'");
expect(source).toContain("status: 'draft'");
});
});

View File

@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
APPROVAL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AgentPersistedMessageContentPart,
@ -16,42 +14,7 @@ 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 ask_credential card with skipped resolved value', () => {
const result = rebuildInteractiveFromHistory({
tool: ASK_CREDENTIAL_TOOL_NAME,
toolCallId: 'call-2',
input: { purpose: 'slack', credentialType: 'slackApi' },
output: { skipped: true },
state: 'done',
});
expect(result?.toolName).toBe(ASK_CREDENTIAL_TOOL_NAME);
expect(result?.resolvedValue).toEqual({ skipped: true });
});
it('returns undefined for non-interactive tool names', () => {
const result = rebuildInteractiveFromHistory({
tool: 'write_config',
@ -107,83 +70,6 @@ describe('rebuildInteractiveFromHistory', () => {
});
describe('convertDbMessages — interactive turn synthesis', () => {
it('reconstructs an OPEN interactive card when tool-call block has state:pending', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm1',
role: 'user',
content: [{ type: 'text', text: 'Build me an agent' }],
},
{
id: 'm2',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'call-q-1',
input: {
questions: [{ id: 'q1', question: 'Which model?', type: 'text' }],
},
state: 'pending',
},
],
},
];
const chat = convertDbMessages(dbMessages);
expect(chat).toHaveLength(2);
const assistant = chat[1];
expect(assistant.role).toBe('assistant');
expect(assistant.status).toBe('awaitingUser');
expect(assistant.interactive?.toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(assistant.interactive?.resolvedAt).toBeUndefined();
expect(assistant.toolCalls?.[0].state).toBe('suspended');
});
it('reconstructs a RESOLVED interactive card when tool-call block has state:resolved', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: ASK_QUESTIONS_TOOL_NAME,
toolCallId: 'q-1',
input: {
questions: [
{
id: 'q1',
question: 'Where to post?',
type: 'single',
options: ['Slack', 'Discord'],
},
],
},
state: 'resolved',
output: { answered: true, answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }] },
},
],
},
];
const chat = convertDbMessages(dbMessages);
expect(chat).toHaveLength(1);
const assistant = chat[0];
expect(assistant.toolCalls?.[0].state).toBe('done');
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({
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['Slack'] }],
});
});
it('preserves multiple resolved n8n chat cards from one persisted assistant message', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
@ -443,11 +329,11 @@ describe('isGroupable', () => {
id: 'm1',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'suspended' }],
toolCalls: [{ tool: 'calculator', toolCallId: 'c1', state: 'suspended' }],
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c1',
input: questionsSuspendInput('Which model?'),
input: { type: 'approval', toolName: 'calculator', args: {} },
},
status: 'awaitingUser',
});
@ -469,21 +355,18 @@ 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_questions card
// First grouped turn: a resolved approval card
{
id: 'm1',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c1', state: 'done' }],
toolCalls: [{ tool: 'tool_a', toolCallId: 'c1', state: 'done' }],
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c1',
input: questionsSuspendInput('Which model?'),
input: { type: 'approval', toolName: 'tool_a', args: {} },
resolvedAt: 1,
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
},
resolvedValue: { approved: true },
},
status: 'success',
},
@ -495,30 +378,29 @@ describe('buildDisplayGroups — interactive payloads', () => {
toolCalls: [{ tool: 'search_nodes', toolCallId: 'c2', state: 'done' }],
status: 'success',
},
// Third grouped turn: an open ask_credential card
// Third grouped turn: an open approval card
{
id: 'm3',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_CREDENTIAL_TOOL_NAME, toolCallId: 'c3', state: 'suspended' }],
toolCalls: [{ tool: 'tool_b', toolCallId: 'c3', state: 'suspended' }],
interactive: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c3',
input: credentialSuspendInput('slackApi', 'Slack'),
input: { type: 'approval', toolName: 'tool_b', args: {} },
},
status: 'awaitingUser',
},
]);
expect(groups).toHaveLength(1);
const grouped = groups[0];
expect(grouped.kind).toBe('toolRun');
if (grouped.kind !== 'toolRun') return;
expect(grouped.toolCalls).toHaveLength(3);
expect(grouped.interactives).toHaveLength(2);
expect(grouped.interactives[0].toolName).toBe(ASK_QUESTIONS_TOOL_NAME);
expect(grouped.interactives[0].input).toMatchObject({ toolName: 'tool_a' });
expect(grouped.interactives[0].resolvedAt).toBeDefined();
expect(grouped.interactives[1].toolName).toBe(ASK_CREDENTIAL_TOOL_NAME);
expect(grouped.interactives[1].input).toMatchObject({ toolName: 'tool_b' });
expect(grouped.interactives[1].resolvedAt).toBeUndefined();
});
@ -624,11 +506,11 @@ describe('applyOpenSuspensions', () => {
id: 'm1',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_CREDENTIAL_TOOL_NAME, toolCallId: 'c-open', state: 'suspended' }],
toolCalls: [{ tool: 'tool_a', toolCallId: 'c-open', state: 'suspended' }],
interactive: {
toolName: ASK_CREDENTIAL_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c-open',
input: credentialSuspendInput('slackApi', 'Slack'),
input: { type: 'approval', toolName: 'tool_a', args: {} },
},
status: 'awaitingUser',
},
@ -636,16 +518,13 @@ describe('applyOpenSuspensions', () => {
id: 'm2',
role: 'assistant',
content: '',
toolCalls: [{ tool: ASK_QUESTIONS_TOOL_NAME, toolCallId: 'c-resolved', state: 'done' }],
toolCalls: [{ tool: 'tool_b', toolCallId: 'c-resolved', state: 'done' }],
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c-resolved',
input: questionsSuspendInput('Which model?'),
input: { type: 'approval', toolName: 'tool_b', args: {} },
resolvedAt: 1,
resolvedValue: {
answered: true,
answers: [{ questionId: 'q1', selectedOptions: ['gpt-4'] }],
},
resolvedValue: { approved: true },
},
status: 'success',
},
@ -665,9 +544,9 @@ describe('applyOpenSuspensions', () => {
role: 'assistant',
content: '',
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c1',
input: questionsSuspendInput('Which model?'),
input: { type: 'approval', toolName: 'tool_a', args: {} },
},
},
];
@ -683,9 +562,9 @@ describe('applyOpenSuspensions', () => {
role: 'assistant',
content: '',
interactive: {
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: APPROVAL_TOOL_NAME,
toolCallId: 'c1',
input: questionsSuspendInput('Which model?'),
input: { type: 'approval', toolName: 'tool_a', args: {} },
},
},
];

View File

@ -1,158 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
} from '@n8n/api-types';
import {
summariseInteractiveOutput,
summariseToolCall,
} from '@/features/ai/shared/agentsChat/interactiveSummary';
import { N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types';
import { summariseToolCall } from '@/features/ai/shared/agentsChat/interactiveSummary';
import { DELEGATE_SUB_AGENT_TOOL_NAME } from '../utils/delegate-tool';
import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool';
describe('summariseInteractiveOutput', () => {
describe('summariseToolCall', () => {
it('returns undefined for non-interactive tool names', () => {
expect(summariseInteractiveOutput('search_nodes', { foo: 'bar' })).toBeUndefined();
expect(summariseToolCall('search_nodes', { foo: 'bar' })).toBeUndefined();
});
it('returns undefined when output is missing', () => {
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, undefined)).toBeUndefined();
expect(summariseToolCall(N8N_CHAT_ACTION_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_QUESTIONS_TOOL_NAME, value)).toBeUndefined();
expect(summariseToolCall(N8N_CHAT_ACTION_TOOL_NAME, value)).toBeUndefined();
},
);
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 },
],
};
expect(summariseInteractiveOutput(ASK_QUESTIONS_TOOL_NAME, output)).toBe('Slack, Discord');
});
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', () => {
expect(
summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, {
credentialId: 'c1',
credentialName: 'My Slack',
}),
).toBe('My Slack');
});
it('renders ask_credential skip', () => {
expect(summariseInteractiveOutput(ASK_CREDENTIAL_TOOL_NAME, { skipped: true })).toBe('Skipped');
});
it('renders the optimistic resume shape as Selected before the real output arrives', () => {
expect(
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',
);
});
});
describe('summariseInteractiveOutput — n8n_chat_action', () => {
const cardInput = {
action: 'respond',
input: {
message: {
card: {
components: [
{ type: 'button', label: 'Approve & Send', value: 'approve_send' },
{
type: 'radio_select',
id: 'next_step',
options: [{ label: 'Schedule a call', value: 'call' }],
},
],
},
},
},
};
it('resolves the clicked button to its label', () => {
expect(
summariseInteractiveOutput(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'button', value: 'approve_send' },
cardInput,
),
).toBe('Approve & Send');
});
it('falls back to button text when label is absent (same precedence as the renderer)', () => {
const textButtonInput = {
action: 'respond',
input: {
message: {
card: { components: [{ type: 'button', text: 'Confirm & Send', value: 'confirm' }] },
},
},
};
expect(
summariseInteractiveOutput(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'button', value: 'confirm' },
textButtonInput,
),
).toBe('Confirm & Send');
});
it('resolves a selected option to its label', () => {
expect(
summariseInteractiveOutput(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'select', id: 'next_step', value: 'call' },
cardInput,
),
).toBe('Schedule a call');
});
it('falls back to the raw value when no component matches', () => {
expect(
summariseInteractiveOutput(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'button', value: 'unknown' },
cardInput,
),
).toBe('unknown');
});
it('returns undefined for display-only action results', () => {
expect(
summariseInteractiveOutput(N8N_CHAT_ACTION_TOOL_NAME, { ok: true }, cardInput),
).toBeUndefined();
});
});
describe('summariseToolCall', () => {
it('does not summarise delegate_subagent; AgentChatToolSteps owns the i18n summary', () => {
expect(
summariseToolCall(
@ -173,3 +40,71 @@ describe('summariseToolCall', () => {
).toBeUndefined();
});
});
describe('summariseToolCall — n8n_chat_action', () => {
const cardInput = {
action: 'respond',
input: {
message: {
card: {
components: [
{ type: 'button', label: 'Approve & Send', value: 'approve_send' },
{
type: 'radio_select',
id: 'next_step',
options: [{ label: 'Schedule a call', value: 'call' }],
},
],
},
},
},
};
it('resolves the clicked button to its label', () => {
expect(
summariseToolCall(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'button', value: 'approve_send' },
cardInput,
),
).toBe('Approve & Send');
});
it('falls back to button text when label is absent (same precedence as the renderer)', () => {
const textButtonInput = {
action: 'respond',
input: {
message: {
card: { components: [{ type: 'button', text: 'Confirm & Send', value: 'confirm' }] },
},
},
};
expect(
summariseToolCall(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'button', value: 'confirm' },
textButtonInput,
),
).toBe('Confirm & Send');
});
it('resolves a selected option to its label', () => {
expect(
summariseToolCall(
N8N_CHAT_ACTION_TOOL_NAME,
{ type: 'select', id: 'next_step', value: 'call' },
cardInput,
),
).toBe('Schedule a call');
});
it('falls back to the raw value when no component matches', () => {
expect(
summariseToolCall(N8N_CHAT_ACTION_TOOL_NAME, { type: 'button', value: 'unknown' }, cardInput),
).toBe('unknown');
});
it('returns undefined for display-only action results', () => {
expect(summariseToolCall(N8N_CHAT_ACTION_TOOL_NAME, { ok: true }, cardInput)).toBeUndefined();
});
});

View File

@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest';
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,
@ -69,23 +68,6 @@ describe('tool-call-details', () => {
).toBeUndefined();
});
it('does not expose resolved interactive tool resume payloads', () => {
expect(
getToolCallDetails({
tool: ASK_QUESTIONS_TOOL_NAME,
output: { values: ['slack'] },
state: TOOL_CALL_STATE.DONE,
}),
).toBeUndefined();
expect(
getToolCallDetails({
tool: ASK_CREDENTIAL_TOOL_NAME,
output: { credentialId: 'c1', credentialName: 'My Slack' },
state: TOOL_CALL_STATE.DONE,
}),
).toBeUndefined();
});
it('shows delegate answers for completed delegations', () => {
expect(
getToolCallDetails({

View File

@ -1,13 +1,7 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref, nextTick } from 'vue';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
type AgentSseEvent,
} from '@n8n/api-types';
import { APPROVAL_TOOL_NAME, N8N_CHAT_ACTION_TOOL_NAME, type AgentSseEvent } from '@n8n/api-types';
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
@ -52,11 +46,11 @@ function makeSseResponse(events: AgentSseEvent[]): Response {
});
}
function buildHook(endpoint: 'build' | 'chat' = 'build') {
function buildHook(continueSessionId?: string) {
return useAgentChatStream({
projectId: ref('p1'),
agentId: ref('a1'),
endpoint: ref<'build' | 'chat'>(endpoint),
...(continueSessionId ? { continueSessionId: ref(continueSessionId) } : {}),
});
}
@ -78,114 +72,6 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
vi.restoreAllMocks();
});
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_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('hello');
await nextTick();
expect(hook.messages.value).toHaveLength(2);
const assistant = hook.messages.value[1];
expect(assistant.role).toBe('assistant');
expect(assistant.status).toBe('awaitingUser');
expect(assistant.toolCalls).toHaveLength(1);
expect(assistant.toolCalls?.[0].state).toBe('suspended');
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();
});
it('flips the card to resolved state when a follow-up `tool-result` carries the matching toolCallId', async () => {
const events: AgentSseEvent[] = [
{
type: 'tool-call-suspended',
payload: {
toolCallId: 'tc-1',
runId: 'run-42',
toolName: ASK_CREDENTIAL_TOOL_NAME,
input: {
requestId: 'req-1',
message: 'Slack',
severity: 'info',
credentialRequests: [
{ credentialType: 'slackApi', reason: 'Slack', existingCredentials: [] },
],
credentialFlow: { stage: 'generic' },
},
},
},
{
type: 'tool-result',
toolCallId: 'tc-1',
toolName: ASK_CREDENTIAL_TOOL_NAME,
output: { credentialId: 'cred-1', credentialName: 'Acme Slack' },
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('add slack');
await nextTick();
const assistant = hook.messages.value[1];
expect(assistant.toolCalls?.[0].state).toBe('done');
expect(assistant.status).toBe('success');
expect(assistant.interactive?.resolvedAt).toBeDefined();
expect(assistant.interactive?.resolvedValue).toEqual({
credentialId: 'cred-1',
credentialName: 'Acme Slack',
});
});
it('does NOT lose the interactive card when tool-call-suspended arrives after a tool-call already ran', async () => {
// SDK ordering: the `tool-call` event lands first, then
// `tool-call-suspended` marks it as awaiting user input.
const events: AgentSseEvent[] = [
{
type: 'tool-call',
toolCallId: 'tc-1',
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_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
},
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('build me an agent');
await nextTick();
const assistant = hook.messages.value[1];
expect(assistant.toolCalls).toHaveLength(1);
expect(assistant.toolCalls?.[0].state).toBe('suspended');
expect(assistant.interactive?.runId).toBe('run-7');
expect(assistant.status).toBe('awaitingUser');
});
it('renders an approval card when preview chat suspends for tool approval', async () => {
const events: AgentSseEvent[] = [
{
@ -211,7 +97,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook('chat');
const hook = buildHook();
await hook.sendMessage('calculate 2 + 2');
await nextTick();
@ -267,7 +153,7 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const hook = buildHook('chat');
const hook = buildHook();
await hook.sendMessage('calculate 2 + 2');
await nextTick();
@ -355,18 +241,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_QUESTIONS_TOOL_NAME },
{ type: 'tool-input-start', toolCallId: 'tc-1', toolName: 'calculator' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
toolName: 'calculator',
input: { input: '2 + 2' },
},
{ type: 'finish-step' },
{
type: 'tool-execution-start',
toolCallId: 'tc-1',
toolName: ASK_QUESTIONS_TOOL_NAME,
toolName: 'calculator',
startTime: 1_000,
},
{
@ -374,8 +260,8 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
payload: {
toolCallId: 'tc-1',
runId: 'run-9',
toolName: ASK_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'Which model?', type: 'text' }] },
toolName: 'calculator',
input: { type: 'approval', toolName: 'calculator', args: { input: '2 + 2' } },
},
},
{ type: 'done' },
@ -932,55 +818,6 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
msg.interactives?.find((payload) => payload.toolCallId === 'tc-card-2')?.resolvedAt,
).toBeUndefined();
});
it('builder tool (ask_questions) still sets tc.input from suspend payload and builds card', async () => {
const askInput = {
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_QUESTIONS_TOOL_NAME,
input: { questions: [{ question: 'placeholder', type: 'single', options: ['a'] }] },
},
{
type: 'tool-call-suspended',
payload: {
toolCallId: 'tc-ask',
runId: 'run-ask',
toolName: ASK_QUESTIONS_TOOL_NAME,
input: askInput,
},
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('set language');
await nextTick();
const msg = hook.messages.value.at(-1)!;
const tc = msg.toolCalls!.find((t) => t.toolCallId === 'tc-ask')!;
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_QUESTIONS_TOOL_NAME);
expect(msg.interactive?.runId).toBe('run-ask');
expect(msg.status).toBe('awaitingUser');
});
});
describe('useAgentChatStream — loadHistory', () => {
@ -1027,12 +864,10 @@ describe('useAgentChatStream — loadHistory', () => {
openSuspensions: [{ toolCallId: 'tc-1', runId: 'run-9' }],
});
// loadHistory is triggered on mount; we need a hook with endpoint='chat'
// (no continueId → getTestChatMessages path)
// loadHistory uses getTestChatMessages when no continue session id is set
const hook = useAgentChatStream({
projectId: ref('p1'),
agentId: ref('a1'),
endpoint: ref<'build' | 'chat'>('chat'),
});
await hook.loadHistory();
@ -1069,7 +904,6 @@ describe('useAgentChatStream — loadHistory', () => {
const hook = useAgentChatStream({
projectId: ref('p1'),
agentId: ref('a1'),
endpoint: ref<'build' | 'chat'>('chat'),
continueSessionId: ref('thread-1'),
});
await hook.loadHistory();

View File

@ -46,7 +46,6 @@ describe('useAgentTelemetry', () => {
};
useAgentTelemetry().trackSubmittedMessage({
agentId: 'ag-1',
mode: 'test',
status: 'draft',
agentConfig: fingerprint,
});

View File

@ -5,15 +5,13 @@ import { computed, ref } from 'vue';
import {
getAgentBuilderSettings,
getAgentBuilderStatus,
updateAgentBuilderSettings,
} from './composables/useAgentBuilderSettingsApi';
const DEFAULT_SETTINGS: AgentBuilderAdminSettings = { mode: 'default' };
/**
* Pinia store for the agent builder admin settings page and the build-UI
* gating empty state.
* Pinia store for the agent builder admin settings page.
*
* Holds only what the dedicated `/agent-builder` endpoints return the
* cross-cutting context (deployment type, available credentials, credential
@ -26,7 +24,6 @@ export const useAgentBuilderSettingsStore = defineStore('agentBuilderSettings',
const isLoading = ref(false);
const isSaving = ref(false);
const settings = ref<AgentBuilderAdminSettings | null>(null);
const isConfigured = ref<boolean>(false);
const draft = ref<AgentBuilderAdminSettings | null>(null);
const effectiveSettings = computed<AgentBuilderAdminSettings>(
@ -41,7 +38,6 @@ export const useAgentBuilderSettingsStore = defineStore('agentBuilderSettings',
function applyResponse(response: AgentBuilderAdminSettingsResponse) {
settings.value = response.settings;
isConfigured.value = response.isConfigured;
draft.value = null;
}
@ -55,11 +51,6 @@ export const useAgentBuilderSettingsStore = defineStore('agentBuilderSettings',
}
}
async function fetchStatus(): Promise<void> {
const status = await getAgentBuilderStatus(rootStore.restApiContext);
isConfigured.value = status.isConfigured;
}
function setMode(next: AgentBuilderAdminSettings['mode']): void {
const base = settings.value ?? DEFAULT_SETTINGS;
if (next === 'default') {
@ -109,13 +100,11 @@ export const useAgentBuilderSettingsStore = defineStore('agentBuilderSettings',
isLoading,
isSaving,
settings,
isConfigured,
draft,
effectiveSettings,
mode,
isDirty,
fetch,
fetchStatus,
setMode,
setCustomSelection,
save,

View File

@ -1,169 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { deriveAgentStatus } from '../composables/agentTelemetry.utils';
import type {
AgentJsonConfig,
AgentJsonMcpServerConfig,
AgentJsonToolRef,
AgentResource,
} from '../types';
import AgentBuilderUnconfiguredEmptyState from './AgentBuilderUnconfiguredEmptyState.vue';
import AgentChatPanel from './AgentChatPanel.vue';
import AgentChatQuickActions from './AgentChatQuickActions.vue';
defineProps<{
initialized: boolean;
projectId: string;
agentId: string;
agentName: string;
agent: AgentResource | null;
localConfig: AgentJsonConfig | null;
connectedTriggers: string[];
initialPrompt?: string;
isBuilderConfigured: boolean;
isFullWidth: boolean;
canEditAgent: boolean;
isBuildChatStreaming: boolean;
beforeBuildSend?: () => Promise<void> | void;
}>();
const emit = defineEmits<{
'config-updated': [];
'build-done': [];
'update:streaming': [streaming: boolean];
'update:tools': [tools: AgentJsonToolRef[]];
'update:mcp-servers': [mcpServers: AgentJsonMcpServerConfig[]];
'update:connected-triggers': [triggers: string[]];
hide: [];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
'agent-published': [agent: AgentResource];
'agent-changed': [];
}>();
const i18n = useI18n();
const hideChatLabel = computed(() => i18n.baseText('agents.builder.chat.hide.ariaLabel'));
const sharedInputDraft = ref('');
</script>
<template>
<aside
:class="$style.chatColumn"
:aria-label="i18n.baseText('agents.builder.chatColumn.ariaLabel')"
data-testid="agent-builder-chat-column"
>
<span v-if="initialized" :class="$style.floatingHideToggle">
<N8nTooltip placement="left" :content="hideChatLabel">
<N8nButton
variant="ghost"
icon-only
size="small"
:class="$style.headerIconBtn"
:aria-label="hideChatLabel"
data-testid="agent-build-chat-hide-toggle"
@click="emit('hide')"
>
<N8nIcon icon="panel-left-close" :size="14" />
</N8nButton>
</N8nTooltip>
</span>
<div :class="$style.chatBody">
<AgentChatPanel
v-if="initialized && isBuilderConfigured"
v-model:input-draft="sharedInputDraft"
:project-id="projectId"
:agent-id="agentId"
mode="inline"
endpoint="build"
:initial-message="initialPrompt"
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
:can-edit-agent="canEditAgent"
:before-send="beforeBuildSend"
@config-updated="emit('config-updated')"
@build-done="emit('build-done')"
@update:streaming="emit('update:streaming', $event)"
>
<template v-if="canEditAgent" #above-input="{ disabled: chatActionsDisabled }">
<div :class="$style.quickActionsRow">
<AgentChatQuickActions
:tools="localConfig?.tools ?? []"
:mcp-servers="localConfig?.mcpServers ?? []"
:project-id="projectId"
:agent-id="agentId"
:connected-triggers="connectedTriggers"
:is-published="
agent?.activeVersionId !== null && agent?.activeVersionId !== undefined
"
:disabled="isBuildChatStreaming || chatActionsDisabled"
@update:tools="emit('update:tools', $event)"
@update:mcp-servers="emit('update:mcp-servers', $event)"
@update:connected-triggers="emit('update:connected-triggers', $event)"
@trigger-added="emit('trigger-added', $event)"
@agent-changed="emit('agent-changed')"
/>
</div>
</template>
</AgentChatPanel>
<AgentBuilderUnconfiguredEmptyState v-if="initialized && !isBuilderConfigured" />
</div>
</aside>
</template>
<style lang="scss" module>
.chatColumn {
position: relative;
display: flex;
flex-direction: column;
background-color: var(--background--surface);
border-right: var(--border);
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.quickActionsRow {
display: flex;
align-items: flex-start;
justify-content: center;
gap: var(--spacing--2xs);
width: 100%;
}
.headerIconBtn {
color: var(--text-color--subtle);
&:hover,
&:focus-visible {
color: var(--text-color);
}
}
.floatingHideToggle {
position: absolute;
top: var(--spacing--2xs);
right: var(--spacing--sm);
z-index: 2;
display: flex;
}
.chatBody {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
}
.chatBody > * {
flex: 1;
min-height: 0;
max-width: 45rem;
margin: 0 auto;
}
</style>

View File

@ -37,14 +37,13 @@ const props = defineProps<{
deletingAgentFileId?: string | null;
appliedSkills: Array<{ id: string; skill: AgentSkill }>;
connectedTriggers: string[];
isBuildChatStreaming: boolean;
canEditAgent: boolean;
executionsDescription: string;
tasksReloadKey?: number;
artifactMode?: boolean;
}>();
const childrenDisabled = computed(() => props.isBuildChatStreaming || !props.canEditAgent);
const childrenDisabled = computed(() => !props.canEditAgent);
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];

View File

@ -21,7 +21,8 @@ import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Brea
import type { DropdownMenuItemProps } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types/action-dropdown';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { AGENT_PREVIEW_VIEW, NEW_AGENT_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
import { AGENT_PREVIEW_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
import { instanceAiCreateAgentRoute } from '@/features/ai/instanceAi/createAgentRoute';
import AgentPublishButton from './AgentPublishButton.vue';
import { useProjectAgentsList } from '../composables/useProjectAgentsList';
@ -110,7 +111,7 @@ function onSwitcherSelect(id: string) {
}
function onCreateAgent() {
void router.push({ name: NEW_AGENT_VIEW, query: { projectId: props.projectId } });
void router.push(instanceAiCreateAgentRoute(props.projectId));
}
function onBreadcrumbSelect(item: PathItem) {

View File

@ -1,392 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { AgentSseEvent } from '@n8n/api-types';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
const props = defineProps<{
projectId: string;
agentId: string;
initialMessage: string;
}>();
const emit = defineEmits<{
done: [];
configUpdated: [];
'update:streaming': [streaming: boolean];
}>();
const i18n = useI18n();
const rootStore = useRootStore();
const MAX_LINES = 7;
interface LogLine {
id: number;
text: string;
}
// Stable per-line ids keep TransitionGroup keys constant as the window
// slides, so surviving lines move instead of leaving + re-entering (which
// would briefly stack them all at the top via `position: absolute`).
let nextLineId = 0;
const logLines = ref<LogLine[]>([]);
const hasDroppedLines = ref(false);
const isStreaming = ref(false);
const hasError = ref(false);
function pushLine(line: string) {
const trimmed = line.replace(/\s+/g, ' ').trim();
if (!trimmed) return;
logLines.value.push({ id: nextLineId++, text: trimmed });
if (logLines.value.length > MAX_LINES) {
logLines.value = logLines.value.slice(-MAX_LINES);
hasDroppedLines.value = true;
}
}
interface StreamState {
textBuffer: string;
doneSeen: boolean;
}
function handleEvent(event: AgentSseEvent, state: StreamState): void {
switch (event.type) {
case 'text-delta': {
state.textBuffer += event.delta;
while (state.textBuffer.includes('\n')) {
const idx = state.textBuffer.indexOf('\n');
pushLine(state.textBuffer.slice(0, idx));
state.textBuffer = state.textBuffer.slice(idx + 1);
}
break;
}
case 'tool-call': {
if (state.textBuffer) {
pushLine(state.textBuffer);
state.textBuffer = '';
}
pushLine(`${formatToolNameForDisplay(event.toolName)}`);
break;
}
case 'tool-result': {
pushLine(`${event.isError ? '✗' : '✓'} ${formatToolNameForDisplay(event.toolName)}`);
break;
}
case 'config-updated':
case 'tool-updated':
emit('configUpdated');
break;
case 'error':
hasError.value = true;
pushLine(`Error: ${event.message}`);
break;
case 'done':
state.doneSeen = true;
break;
default:
break;
}
}
async function streamBuild(message: string) {
isStreaming.value = true;
emit('update:streaming', true);
const { baseUrl } = rootStore.restApiContext;
const browserId = localStorage.getItem('n8n-browserId') ?? '';
const url = `${baseUrl}/projects/${props.projectId}/agents/v2/${props.agentId}/build`;
const state: StreamState = { textBuffer: '', doneSeen: false };
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'browser-id': browserId },
credentials: 'include',
body: JSON.stringify({ message }),
});
if (!response.ok || !response.body) {
hasError.value = true;
pushLine(`Error: ${response.statusText || 'Failed to reach builder'}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
readerLoop: while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let event: AgentSseEvent;
try {
event = JSON.parse(line.slice(6)) as AgentSseEvent;
} catch {
continue;
}
handleEvent(event, state);
if (state.doneSeen) break readerLoop;
}
}
if (state.textBuffer.trim()) pushLine(state.textBuffer);
} catch (e) {
hasError.value = true;
pushLine(`Error: ${e instanceof Error ? e.message : 'Unknown error'}`);
} finally {
isStreaming.value = false;
emit('update:streaming', false);
// Always hand off to the parent so suspended-tool streams (which close
// without a final `done`) still route into the builder where the
// interactive card can actually be answered. Distinguishing genuine
// failure from suspension needs more thought tracking separately.
emit('done');
}
}
onMounted(() => {
void streamBuild(props.initialMessage);
});
</script>
<template>
<div :class="$style.progress">
<div :class="$style.centerColumn">
<div :class="$style.loader" aria-hidden="true">
<N8nIcon v-if="hasError" icon="triangle-alert" :size="40" />
<svg
v-else
:class="[$style.nodeLoader, isStreaming ? $style.nodeLoaderActive : '']"
width="32"
height="26"
viewBox="0 0 32 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path pathLength="1" d="M4.8 13H8" />
<path
pathLength="1"
d="M14.4 13H15.6C16.9 13 17.9 12.1 18.1 10.9L18.2 10.3C18.5 8.6 19.9 7.4 21.6 7.4H24"
/>
<path
pathLength="1"
d="M14.4 13H15.6C16.9 13 17.9 13.9 18.1 15.1L18.2 15.7C18.5 17.4 19.9 18.6 21.6 18.6H22.4"
/>
<circle pathLength="1" cx="3.2" cy="13" r="2.4" />
<circle pathLength="1" cx="11.2" cy="13" r="2.4" />
<circle pathLength="1" cx="27.2" cy="8.2" r="2.4" />
<circle pathLength="1" cx="24" cy="17.8" r="2.4" />
</svg>
</div>
<N8nText tag="h3" bold step="xl" :class="$style.heading">
{{
hasError
? i18n.baseText('agents.builder.progress.error.title')
: i18n.baseText('agents.builder.progress.building.title')
}}
</N8nText>
<N8nText size="small" color="text-light" :class="$style.subheading">
{{
hasError
? i18n.baseText('agents.builder.progress.error.hint')
: i18n.baseText('agents.builder.progress.building.hint')
}}
</N8nText>
<div :class="[$style.logBox, hasDroppedLines && $style.logBoxFaded]" aria-live="polite">
<TransitionGroup name="builder-line" tag="div" :class="$style.logInner">
<div v-for="line in logLines" :key="line.id" :class="$style.line">
{{ line.text }}
</div>
</TransitionGroup>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.progress {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--2xl) var(--spacing--xl);
overflow: hidden;
}
.centerColumn {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 720px;
gap: var(--spacing--2xs);
}
.loader {
color: #ea4b71;
margin-bottom: var(--spacing--sm);
}
.nodeLoader {
display: block;
width: var(--spacing--3xl);
height: auto;
overflow: visible;
}
.nodeLoader path,
.nodeLoader circle {
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.nodeLoaderActive path,
.nodeLoaderActive circle {
animation: drawNode 2400ms cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.nodeLoaderActive path:nth-of-type(1) {
animation-delay: 0ms;
}
.nodeLoaderActive circle:nth-of-type(1) {
animation-delay: 0ms;
}
.nodeLoaderActive path:nth-of-type(2) {
animation-delay: 280ms;
}
.nodeLoaderActive path:nth-of-type(3) {
animation-delay: 420ms;
}
.nodeLoaderActive circle:nth-of-type(2) {
animation-delay: 200ms;
}
.nodeLoaderActive circle:nth-of-type(3) {
animation-delay: 620ms;
}
.nodeLoaderActive circle:nth-of-type(4) {
animation-delay: 760ms;
}
@keyframes drawNode {
0% {
stroke-dashoffset: 1;
opacity: 0;
}
16% {
stroke-dashoffset: 0;
opacity: 1;
}
46% {
stroke-dashoffset: 0;
opacity: 1;
}
58% {
stroke-dashoffset: -1;
opacity: 0;
}
100% {
stroke-dashoffset: -1;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.nodeLoader path,
.nodeLoader circle {
animation: none;
stroke-dashoffset: 0;
opacity: 1;
}
}
.heading {
margin: 0;
color: var(--color--text);
}
.subheading {
margin-bottom: var(--spacing--xl);
text-align: center;
}
.logBox {
width: 42ch;
max-width: 100%;
max-height: 140px;
overflow: hidden;
position: relative;
}
/* Soft fade at the top so older lines visually dissolve, applied only once
the log is at capacity and older lines are being dropped */
.logBoxFaded {
mask-image: linear-gradient(to bottom, transparent 0, black 32px, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 32px, black 100%);
}
.logInner {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.line {
font-family: var(--font-family--monospace);
font-size: var(--font-size--2xs);
line-height: var(--line-height--xl);
color: var(--color--text);
text-align: center;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.builder-line-enter-active),
:global(.builder-line-leave-active),
:global(.builder-line-move) {
transition:
opacity 200ms ease,
transform 200ms ease;
}
:global(.builder-line-enter-from) {
opacity: 0;
transform: translateY(4px);
}
:global(.builder-line-leave-to) {
opacity: 0;
transform: translateY(-4px);
}
:global(.builder-line-leave-active) {
/* Span the full row while out of flow so text-align: center keeps the
leaving line centered instead of shrinking to fit at the left edge */
position: absolute;
left: 0;
width: 100%;
}
</style>

View File

@ -1,70 +0,0 @@
<script setup lang="ts">
import { N8nButton, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useRouter } from 'vue-router';
import { AGENT_BUILDER_SETTINGS_VIEW } from '../constants';
const router = useRouter();
const i18n = useI18n();
const usersStore = useUsersStore();
const isAdmin = computed(() => usersStore.isInstanceOwner);
function onConfigure() {
void router.push({ name: AGENT_BUILDER_SETTINGS_VIEW });
}
</script>
<template>
<div :class="$style.container" data-test-id="agent-builder-unconfigured">
<div :class="$style.iconWrap">
<N8nIcon icon="settings" :size="32" />
</div>
<N8nHeading tag="h2" size="medium">
{{ i18n.baseText('agents.builder.unconfigured.title') }}
</N8nHeading>
<N8nText size="medium" color="text-light" :class="$style.description">
{{
isAdmin
? i18n.baseText('agents.builder.unconfigured.description.admin')
: i18n.baseText('agents.builder.unconfigured.description.nonAdmin')
}}
</N8nText>
<N8nButton v-if="isAdmin" type="primary" size="medium" @click="onConfigure">
{{ i18n.baseText('agents.builder.unconfigured.cta') }}
</N8nButton>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--sm);
padding: var(--spacing--2xl);
margin: auto;
max-width: 480px;
text-align: center;
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--color--background--light-2);
color: var(--color--text--tint-1);
margin-bottom: var(--spacing--xs);
}
.description {
margin-bottom: var(--spacing--xs);
}
</style>

View File

@ -1,27 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
const props = defineProps<{
endpoint: 'build' | 'chat';
}>();
const icon = computed(() => (props.endpoint === 'build' ? 'wand-sparkles' : 'message-square'));
const title = computed(() =>
props.endpoint === 'build' ? 'Build your agent' : 'Chat with your agent',
);
const subtitle = computed(() =>
props.endpoint === 'build'
? 'Describe what you want your agent to do'
: 'Send a message to start a conversation',
);
const i18n = useI18n();
</script>
<template>
<div :class="[$style.emptyState, endpoint === 'build' && $style.buildEmptyState]">
<N8nIcon :icon="icon" :size="32" color="text-light" />
<N8nText tag="p" bold>{{ title }}</N8nText>
<N8nText size="small" color="text-light">{{ subtitle }}</N8nText>
<div :class="$style.emptyState">
<N8nIcon icon="message-square" :size="32" color="text-light" />
<N8nText tag="p" bold>{{ i18n.baseText('agents.chat.emptyState.title') }}</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.chat.emptyState.description') }}
</N8nText>
</div>
</template>

View File

@ -27,7 +27,6 @@ const props = defineProps<{
messages: ChatMessage[];
messagingState: 'idle' | 'waitingFirstChunk' | 'receiving';
projectId?: string;
agentId?: string;
}>();
const emit = defineEmits<{
@ -50,10 +49,9 @@ function isIntegrationActionSuspend(value: unknown): value is { type: 'integrati
/**
* Returns a display name for the external platform a tool call is waiting on,
* or `undefined` when the tool call either isn't suspended or renders its own
* interactive card. Builder tools never match (their suspend payload is their
* renderable input, not an integration_action sidecar); n8n_chat_action DOES
* carry the sidecar but is excluded explicitly because it renders its own
* interactive card in the chat.
* interactive card. n8n_chat_action carries the integration_action sidecar
* but is excluded explicitly because it renders its own interactive card in
* the chat.
*/
function externalWaitPlatform(tc: ToolCall): string | undefined {
if (tc.state !== TOOL_CALL_STATE.SUSPENDED) return undefined;
@ -65,10 +63,9 @@ function externalWaitPlatform(tc: ToolCall): string | undefined {
/**
* Open cards always render. Once resolved, answered interactive cards clear
* from the chat (builder cards collapse into their tool-step summary; n8n
* chat cards leave the picked answer there too) but display-only n8n chat
* cards persist: they are content, and being born resolved they would
* otherwise never render at all.
* from the chat (both approval and n8n chat cards collapse into their
* tool-step summary) but display-only n8n chat cards persist: they are
* content, and being born resolved they would otherwise never render at all.
*/
function shouldRenderInteractive(payload: InteractivePayload): boolean {
if (!payload.resolvedAt) return !!payload.runId;
@ -437,8 +434,6 @@ onBeforeUnmount(() => {
v-for="payload in group.interactives.filter(shouldRenderInteractive)"
:key="payload.toolCallId"
:payload="payload"
:project-id="projectId"
:agent-id="agentId"
@submit="onInteractiveSubmit(payload, $event)"
/>
</div>
@ -536,8 +531,6 @@ onBeforeUnmount(() => {
<div v-else :class="$style.interactives">
<InteractiveCard
:payload="item.payload"
:project-id="projectId"
:agent-id="agentId"
@submit="onInteractiveSubmit(item.payload, $event)"
/>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, toRef, watch, onMounted, onBeforeUnmount } from 'vue';
import { N8nButton, N8nCallout, N8nIconButton } from '@n8n/design-system';
import { N8nCallout, N8nIconButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { APPROVAL_TOOL_NAME } from '@n8n/api-types';
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
@ -11,7 +11,6 @@ import AgentChatMessageList from './AgentChatMessageList.vue';
import type { AgentJsonConfig } from '../types';
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import { buildAgentConfigFingerprint } from '../composables/agentTelemetry.utils';
import { AGENT_BUILDER_PROMPT_MAX_LENGTH } from '../constants';
const props = withDefaults(
defineProps<{
@ -19,8 +18,6 @@ const props = withDefaults(
projectId: string;
agentId: string;
mode?: 'panel' | 'inline';
endpoint?: 'build' | 'chat';
initialMessage?: string;
continueSessionId?: string;
agentConfig: AgentJsonConfig | null;
agentStatus: 'draft' | 'production';
@ -32,8 +29,6 @@ const props = withDefaults(
{
visible: true,
mode: 'panel',
endpoint: 'chat',
initialMessage: undefined,
continueSessionId: undefined,
canEditAgent: true,
beforeSend: undefined,
@ -42,16 +37,9 @@ const props = withDefaults(
);
const emit = defineEmits<{
codeUpdated: [];
codeDelta: [delta: string];
configUpdated: [];
buildDone: [];
'update:streaming': [streaming: boolean];
'update:inputDraft': [value: string];
'continue-loaded': [count: number];
'initial-consumed': [];
back: [];
'open-build': [];
}>();
const locale = useI18n();
@ -84,27 +72,18 @@ const {
} = useAgentChatStream({
projectId: toRef(props, 'projectId'),
agentId: toRef(props, 'agentId'),
endpoint: toRef(props, 'endpoint'),
continueSessionId: toRef(props, 'continueSessionId'),
onCodeUpdated: () => emit('codeUpdated'),
onCodeDelta: (d) => emit('codeDelta', d),
onConfigUpdated: () => emit('configUpdated'),
onBuildDone: () => emit('buildDone'),
onHistoryLoaded: (count) => {
if (props.continueSessionId) emit('continue-loaded', count);
},
});
function humaniseMissingField(field: string): string {
// `skill:<id>` is a parameterised token render it through a single i18n
// entry so a new id doesn't require a translations change.
if (field.startsWith('skill:')) {
return locale.baseText('agents.chat.misconfigured.missing.skill', {
interpolate: { id: field.slice('skill:'.length) },
});
}
// Map backend-emitted field ids onto i18n keys. Unknown fields fall back to
// their raw id so a new backend-side value still renders something useful.
const key = `agents.chat.misconfigured.missing.${field}`;
const translated = locale.baseText(key as never);
return translated === key ? field : translated;
@ -125,38 +104,20 @@ const areConfigurationActionsDisabled = computed(
() => isStreaming.value || isPreparingToSend.value || hasOpenInteraction.value,
);
const isBuilderReadOnly = computed(() => props.endpoint === 'build' && !props.canEditAgent);
const chatPlaceholder = computed(() =>
isBuilderReadOnly.value
? locale.baseText('agents.builder.readonly.placeholder')
: hasOpenApproval.value
? locale.baseText('agents.chat.approval.inputPlaceholder')
: hasOpenInteractiveQuestion.value
? locale.baseText('agents.chat.answerQuestionPlaceholder')
: locale.baseText('agents.chat.input.placeholder'),
hasOpenApproval.value
? locale.baseText('agents.chat.approval.inputPlaceholder')
: hasOpenInteractiveQuestion.value
? locale.baseText('agents.chat.answerQuestionPlaceholder')
: locale.baseText('agents.chat.input.placeholder'),
);
function onOpenBuild() {
dismissFatalError();
emit('open-build');
}
watch(isStreaming, (v) => emit('update:streaming', v));
async function onSubmit() {
const text = inputText.value.trim();
if (
!text ||
isStreaming.value ||
isPreparingToSend.value ||
isBuilderReadOnly.value ||
hasOpenApproval.value
)
return;
if (!text || isStreaming.value || isPreparingToSend.value || hasOpenApproval.value) return;
// When there is an open interactive question, the user's message cancels
// the suspended tool and steers the agent in a new direction.
if (hasOpenInteractiveQuestion.value) {
inputText.value = '';
await cancelAndSteer(text);
@ -167,7 +128,6 @@ async function onSubmit() {
try {
await props.beforeSend?.();
} catch {
// Autosave errors are surfaced by the caller that owns the flush.
isPreparingToSend.value = false;
return;
}
@ -181,7 +141,6 @@ async function onSubmit() {
);
agentTelemetry.trackSubmittedMessage({
agentId: props.agentId,
mode: props.endpoint === 'build' ? 'build' : 'test',
status: props.agentStatus,
agentConfig: fingerprint,
});
@ -200,52 +159,10 @@ function sendMessageFromOutside(message: string) {
defineExpose({ sendMessageFromOutside });
// Capture the seed message locally so later clearing of `props.initialMessage`
// by the parent (which does so on `nextTick` to prevent the same prompt
// bleeding into the other chat panel) can't race the `onMounted` guard below.
const seedMessage = props.initialMessage;
// Seed the initial message synchronously during setup (not onMounted) so the
// user bubble is in `messages` before Vue performs the first render. Without
// this, the panel renders once with an empty message list and THEN the user
// message appears visible as a 1-frame flash of the blank/centered layout.
//
// `sendMessage` is an async function but the push to `messages` happens
// before any await, so calling it here runs the sync prefix (push + set
// `isStreaming = true` inside streamFromEndpoint) before setup returns. The
// fetch itself continues to run async in the background.
async function sendSeedMessage(message: string): Promise<void> {
try {
await props.beforeSend?.();
const sending = sendMessage(message);
emit('initial-consumed');
await sending;
} catch {
// Autosave errors are surfaced by the caller that owns the flush.
}
}
// Skip the seed when the build chat is read-only
const consumesSeed = !!seedMessage && !isBuilderReadOnly.value;
if (consumesSeed) {
void sendSeedMessage(seedMessage as string);
}
onMounted(() => {
// When we actually seeded a message, there's no prior thread to load
// the agent was just created in this panel and the history endpoint
// would 404. Otherwise (including the read-only suppression path) load
// whatever history exists so the panel shows real content instead of a
// misleading "describe your agent" empty state.
if (consumesSeed) {
return;
}
void loadHistory();
});
// Abort any in-flight stream when the panel unmounts (e.g. route change,
// chat mode reset). Without this the fetch keeps running and its reader
// accumulates bytes until the browser gc's it.
onBeforeUnmount(() => {
stopGenerating();
});
@ -263,14 +180,6 @@ onBeforeUnmount(() => {
</span>
</div>
<template #trailingContent>
<N8nButton
variant="outline"
size="xsmall"
data-testid="agent-misconfigured-open-build"
@click="onOpenBuild"
>
{{ locale.baseText('agents.chat.misconfigured.openBuild') }}
</N8nButton>
<N8nIconButton
icon="x"
variant="ghost"
@ -282,22 +191,12 @@ onBeforeUnmount(() => {
</template>
</N8nCallout>
<!--
Suppress the centered empty state when we have an `initialMessage` to
seed. Without this, the panel briefly renders the empty view before
the seedMessage push (during setup) lands in `messages` visible as
a flicker of the centered layout under the mode transition.
-->
<AgentChatEmptyState
v-if="messages.length === 0 && !isStreaming && !initialMessage"
:endpoint="endpoint"
/>
<AgentChatEmptyState v-if="messages.length === 0 && !isStreaming" />
<AgentChatMessageList
v-else
:messages="messages"
:messaging-state="messagingState"
:project-id="projectId"
:agent-id="agentId"
@resume="resume"
/>
@ -308,19 +207,11 @@ onBeforeUnmount(() => {
:placeholder="chatPlaceholder"
:is-streaming="messagingState === 'receiving'"
:can-submit="
!hasOpenApproval &&
!isStreaming &&
!isPreparingToSend &&
!isBuilderReadOnly &&
inputText.trim().length > 0
!hasOpenApproval && !isStreaming && !isPreparingToSend && inputText.trim().length > 0
"
:disabled="
isBuilderReadOnly ||
hasOpenApproval ||
isPreparingToSend ||
(isStreaming && messagingState !== 'receiving')
hasOpenApproval || isPreparingToSend || (isStreaming && messagingState !== 'receiving')
"
:max-length="endpoint === 'build' ? AGENT_BUILDER_PROMPT_MAX_LENGTH : undefined"
data-testid="chat-input"
@submit="onSubmit"
@stop="stopGenerating"

View File

@ -1,137 +0,0 @@
<script setup lang="ts">
/**
* Quick-action chips pinned above the chat input.
* - Add tool: opens `AgentToolsModal` via the shared modal system and
* re-emits the confirmed tools upward as `update:tools`.
* - Add trigger: opens `AgentChannelModal` and re-emits connected-channel
* updates + trigger-added events upward.
*/
import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
import { useUIStore } from '@/app/stores/ui.store';
import { AGENT_TOOLS_MODAL_KEY } from '../constants';
import type { AgentJsonMcpServerConfig, AgentJsonToolRef } from '../types';
import AgentChipButton from './AgentChipButton.vue';
import AgentChannelModal, { type ChannelView } from './AgentChannelModal.vue';
const props = defineProps<{
tools: AgentJsonToolRef[];
mcpServers?: AgentJsonMcpServerConfig[];
projectId: string;
agentId: string;
connectedTriggers: string[];
isPublished: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:tools': [tools: AgentJsonToolRef[]];
'update:mcp-servers': [mcpServers: AgentJsonMcpServerConfig[]];
'update:connected-triggers': [triggers: string[]];
'trigger-added': [payload: { triggerType: string; triggers: string[] }];
'agent-changed': [];
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const channelModalOpen = ref(false);
const channelModalView = ref<ChannelView>('list');
function onAddTool() {
if (props.disabled) return;
uiStore.openModalWithData({
name: AGENT_TOOLS_MODAL_KEY,
data: {
tools: props.tools,
mcpServers: props.mcpServers ?? [],
projectId: props.projectId,
agentId: props.agentId,
onConfirm: (props: {
tools?: AgentJsonToolRef[];
mcpServers?: AgentJsonMcpServerConfig[];
}) => {
if (props.tools) {
emit('update:tools', props.tools);
}
if (props.mcpServers) {
emit('update:mcp-servers', props.mcpServers);
}
},
},
});
}
function onAddTrigger() {
if (props.disabled) return;
channelModalView.value = 'list';
channelModalOpen.value = true;
}
function handleChannelConnected(channelType: string) {
const triggers = Array.from(new Set([...props.connectedTriggers, channelType]));
emit('update:connected-triggers', triggers);
emit('trigger-added', { triggerType: channelType, triggers });
}
function handleChannelDisconnected(channelType: string) {
emit(
'update:connected-triggers',
props.connectedTriggers.filter((trigger) => trigger !== channelType),
);
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.row" data-testid="agent-chat-quick-actions">
<AgentChipButton
variant="suggestion"
icon="wrench"
:disabled="props.disabled"
data-testid="agent-quick-action-add-tool"
@click="onAddTool"
>
{{ i18n.baseText('agents.builder.quickActions.addTool') }}
</AgentChipButton>
<AgentChipButton
variant="suggestion"
icon="zap"
:disabled="props.disabled"
data-testid="agent-quick-action-add-trigger"
@click="onAddTrigger"
>
{{ i18n.baseText('agents.builder.quickActions.addTrigger') }}
</AgentChipButton>
</div>
<AgentChannelModal
v-if="channelModalOpen"
v-model:open="channelModalOpen"
v-model:view="channelModalView"
:agent-id="agentId"
:project-id="projectId"
:connected-channels="connectedTriggers"
:is-published="isPublished"
@channel-connected="handleChannelConnected"
@channel-disconnected="handleChannelDisconnected"
@agent-changed="emit('agent-changed')"
/>
</div>
</template>
<style lang="scss" module>
.container {
display: contents;
}
.row {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: var(--spacing--2xs);
width: 100%;
}
</style>

View File

@ -13,13 +13,10 @@ defineProps<{
localConfig: AgentJsonConfig | null;
connectedTriggers: string[];
effectiveSessionId?: string;
initialPrompt?: string;
}>();
const emit = defineEmits<{
'config-updated': [];
'continue-loaded': [count: number];
'open-build': [];
}>();
const inputDraft = ref('');
@ -35,15 +32,11 @@ const inputDraft = ref('');
:project-id="projectId"
:agent-id="agentId"
mode="inline"
endpoint="chat"
:initial-message="initialPrompt"
:continue-session-id="effectiveSessionId"
:agent-config="localConfig"
:agent-status="deriveAgentStatus(agent)"
:connected-triggers="connectedTriggers"
@config-updated="emit('config-updated')"
@continue-loaded="emit('continue-loaded', $event)"
@open-build="emit('open-build')"
/>
</div>
</main>

View File

@ -1,294 +0,0 @@
<script setup lang="ts">
/**
* 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 } 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 { useWizardNavigation } from '@/features/ai/shared/composables/useWizardNavigation';
import { getAppNameFromCredType } from '@/app/utils/nodeTypesUtils';
import type { INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import { ChatHubToolContextKey } from '@/app/constants';
import type { CredentialResolvedValue } from '@/features/ai/shared/agentsChat/types';
const props = defineProps<{
credentialRequests: InstanceAiCredentialRequest[];
message: string;
projectId?: string;
disabled?: boolean;
resolvedValue?: CredentialResolvedValue;
}>();
const emit = defineEmits<{
submit: [resumeData: CredentialResumeData];
}>();
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
provide(ChatHubToolContextKey, true);
const totalSteps = computed(() => props.credentialRequests.length);
const { currentStepIndex, isPrevDisabled, isNextDisabled, goToNext, goToPrev, goToStep } =
useWizardNavigation({ totalSteps });
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)),
);
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: req.credentialType,
name: req.credentialType,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: selectedCred
? { [req.credentialType]: { id: selectedCred.id, name: selectedCred.name } }
: {},
} as INodeUi;
}
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 (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">{{ message }}</N8nText>
<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"
data-testid="ask-credential-skip"
@click="onSkip"
>
{{ i18n.baseText('agents.chat.askCredential.skip') }}
</N8nButton>
</div>
<div v-else :class="$style.resolvedRow">
<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">{{ resolvedLabel ?? '—' }}</N8nText>
</template>
</div>
</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;
}
.cardBody {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.purpose {
margin: 0;
font-size: var(--font-size--sm);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.credentialContainer {
display: flex;
flex-direction: column;
// NodeCredentials adds its own top margin which double-stacks inside our card chrome.
:global(.node-credentials) {
margin-top: 0;
}
}
.actions {
display: flex;
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;
gap: var(--spacing--2xs);
color: var(--color--success);
}
</style>

View File

@ -1,122 +0,0 @@
<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>

View File

@ -1,81 +0,0 @@
<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>

View File

@ -1,44 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
APPROVAL_TOOL_NAME,
ASK_CREDENTIAL_TOOL_NAME,
ASK_EMBEDDING_CREDENTIAL_TOOL_NAME,
ASK_QUESTIONS_TOOL_NAME,
CONFIGURE_CHANNEL_TOOL_NAME,
N8N_CHAT_ACTION_TOOL_NAME,
} from '@n8n/api-types';
import { APPROVAL_TOOL_NAME, N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types';
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 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. `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 the channel card
* (which talks to the integrations API using them directly). The credential
* card also accepts `projectId` but works without it.
* `chat_action` dispatch by `toolName` their payload shape isn't shared
* with any other surface, so `toolName` is a reliable, TS-narrowing
* discriminant for both `matches` and `getProps`.
*/
const props = defineProps<{
payload: InteractivePayload;
projectId?: string;
agentId?: string;
}>();
const emit = defineEmits<{
@ -53,35 +29,6 @@ const emit = defineEmits<{
*/
const disabled = computed(() => !!props.payload.resolvedAt || !props.payload.runId);
/**
* 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 = [
{
key: 'approval',
@ -95,52 +42,6 @@ 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) => hasCredentialRequestsInput(payload),
getProps: (payload, context) => {
if (
payload.toolName !== ASK_CREDENTIAL_TOOL_NAME &&
payload.toolName !== ASK_EMBEDDING_CREDENTIAL_TOOL_NAME
) {
return {};
}
return {
credentialRequests: payload.input.credentialRequests,
message: payload.input.message,
projectId: context?.projectId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'configure_channel',
component: ConfigureChannelCard,
matches: (payload) => hasChannelConfigInput(payload),
getProps: (payload) => {
if (payload.toolName !== CONFIGURE_CHANNEL_TOOL_NAME) return {};
return {
integrationType: payload.input.channelConfig.integrationType,
agentId: payload.input.channelConfig.agentId,
projectId: payload.input.projectId,
resolvedValue: payload.resolvedValue,
};
},
},
{
key: 'chat_action',
component: N8nChatActionCard,
@ -165,7 +66,6 @@ function onSubmit(resumeData: unknown) {
:payload="payload"
:renderers="interactiveRenderers"
:disabled="disabled"
:context="{ projectId, agentId }"
@submit="onSubmit"
/>
</template>

View File

@ -1,5 +1,4 @@
import type {
AgentBuilderMessagesResponse,
AgentCapabilitySummary,
AgentChatMessagesResponse,
AgentFileDto,
@ -497,30 +496,6 @@ export const updateAgentSkill = async (
);
};
export const getBuilderMessages = async (
context: IRestApiContext,
projectId: string,
agentId: string,
): Promise<AgentBuilderMessagesResponse> => {
return await makeRestApiRequest<AgentBuilderMessagesResponse>(
context,
'GET',
`/projects/${projectId}/agents/v2/${agentId}/build/messages`,
);
};
export const clearBuilderMessages = async (
context: IRestApiContext,
projectId: string,
agentId: string,
): Promise<void> => {
await makeRestApiRequest(
context,
'DELETE',
`/projects/${projectId}/agents/v2/${agentId}/build/messages`,
);
};
export const getChatMessages = async (
context: IRestApiContext,
projectId: string,

View File

@ -1,7 +1,6 @@
import type {
AgentBuilderAdminSettingsResponse,
AgentBuilderAdminSettingsUpdateRequest,
AgentBuilderStatusResponse,
} from '@n8n/api-types';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
@ -23,8 +22,3 @@ export const updateAgentBuilderSettings = async (
`${BASE}/settings`,
payload as unknown as Record<string, unknown>,
);
export const getAgentBuilderStatus = async (
context: IRestApiContext,
): Promise<AgentBuilderStatusResponse> =>
await makeRestApiRequest<AgentBuilderStatusResponse>(context, 'GET', `${BASE}/status`);

View File

@ -1,16 +0,0 @@
import { useRootStore } from '@n8n/stores/useRootStore';
import { ref } from 'vue';
import { getAgentBuilderStatus } from './useAgentBuilderSettingsApi';
export function useAgentBuilderStatus() {
const rootStore = useRootStore();
const isBuilderConfigured = ref(false);
async function fetchStatus(): Promise<void> {
const status = await getAgentBuilderStatus(rootStore.restApiContext);
isBuilderConfigured.value = status.isConfigured;
}
return { isBuilderConfigured, fetchStatus };
}

View File

@ -377,14 +377,6 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
});
}
function onQuickActionAddTool(tools: AgentJsonToolConfig[]) {
scheduleConfigUpdate({ tools });
}
function onQuickActionAddMcpServers(mcpServers: AgentJsonMcpServerConfig[]) {
scheduleConfigUpdate({ mcpServers });
}
function onConnectedTriggersUpdate(triggers: string[]) {
connectedTriggers.value = triggers;
telemetry?.trackTriggerListChanged?.(triggers);
@ -400,8 +392,6 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
onOpenAddToolModal,
onOpenToolFromList,
onRemoveTool,
onQuickActionAddTool,
onQuickActionAddMcpServers,
onOpenAddSkillModal,
onOpenSkillFromList,
onRemoveSkill,

View File

@ -1,4 +1,4 @@
import { ref, reactive, computed, nextTick, type Ref } from 'vue';
import { ref, reactive, computed, type Ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import type {
@ -8,13 +8,7 @@ import type {
CancellationResumeData,
} from '@n8n/api-types';
import { useToast } from '@/app/composables/useToast';
import {
getBuilderMessages,
clearBuilderMessages,
getChatMessages,
getTestChatMessages,
clearTestChatMessages,
} from './useAgentApi';
import { getChatMessages, getTestChatMessages, clearTestChatMessages } from './useAgentApi';
import {
applyOpenSuspensions,
@ -23,7 +17,6 @@ import {
getMessageInteractive,
getMessageInteractives,
isApprovalSuspendInput,
isInteractiveToolName,
rebuildInteractiveFromHistory,
setMessageInteractives,
upsertMessageInteractive,
@ -41,17 +34,12 @@ export interface FatalAgentError {
export interface UseAgentChatStreamParams {
projectId: Ref<string>;
agentId: Ref<string>;
endpoint: Ref<'build' | 'chat'>;
/**
* When provided, chat mode runs in session-continuation: history is fetched
* per-thread and the id is propagated to the backend so further messages
* extend the same session.
*/
continueSessionId?: Ref<string | undefined>;
onCodeUpdated?: () => void;
onCodeDelta?: (delta: string) => void;
onConfigUpdated?: () => void;
onBuildDone?: () => void;
onHistoryLoaded?: (count: number) => void;
}
@ -97,15 +85,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
try {
let dbMessages: AgentPersistedMessageDto[];
let openSuspensions: AgentBuilderOpenSuspension[] = [];
if (params.endpoint.value === 'build') {
const envelope = await getBuilderMessages(
rootStore.restApiContext,
params.projectId.value,
params.agentId.value,
);
dbMessages = envelope.messages;
openSuspensions = envelope.openSuspensions;
} else if (continueId) {
if (continueId) {
const envelope = await getChatMessages(
rootStore.restApiContext,
params.projectId.value,
@ -124,11 +104,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
openSuspensions = envelope.openSuspensions;
}
if (dbMessages.length > 0) {
const context = { agentId: params.agentId.value, projectId: params.projectId.value };
messages.value = applyOpenSuspensions(
convertDbMessages(dbMessages, context),
openSuspensions,
);
messages.value = applyOpenSuspensions(convertDbMessages(dbMessages), openSuspensions);
}
params.onHistoryLoaded?.(messages.value.length);
} catch (error) {
@ -147,10 +123,12 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
}
async function clearHistory(): Promise<void> {
const clearRemote =
params.endpoint.value === 'build' ? clearBuilderMessages : clearTestChatMessages;
try {
await clearRemote(rootStore.restApiContext, params.projectId.value, params.agentId.value);
await clearTestChatMessages(
rootStore.restApiContext,
params.projectId.value,
params.agentId.value,
);
messages.value = [];
} catch (error) {
showError(error, locale.baseText('agents.chat.clearHistory.error'));
@ -162,7 +140,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
// -------------------------------------------------------------------------
interface StreamSession {
builderMutated: boolean;
/**
* Set when the stream emitted an `error` event. Callers (notably
* `resume`) inspect this so they can roll back optimistic UI state
@ -275,8 +252,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
break;
}
case 'tool-input-delta':
// Streaming tool input — `code-delta` handles the build-tool case.
// No ToolCall state mutation here.
// Streaming tool input isn't rendered incrementally; the full
// input arrives on `tool-call`. No ToolCall state mutation here.
break;
case 'tool-call': {
// LLM finalized the call. Update input on the existing entry,
@ -371,11 +348,10 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
case 'tool-call-suspended': {
const { payload } = event;
const found = findToolCallById(payload.toolCallId);
// Builder interactive tools (ask_* / approval) suspend with their
// renderable input; integration actions suspend with a sidecar —
// keep the card-bearing tool input and store the sidecar separately.
const suspendIsRenderableInput =
isInteractiveToolName(payload.toolName) || isApprovalSuspendInput(payload.input);
// The approval tool suspends with its renderable input; integration
// actions suspend with a sidecar — keep the card-bearing tool input
// and store the sidecar separately.
const suspendIsRenderableInput = isApprovalSuspendInput(payload.input);
let msg: ChatMessage;
let tc: ToolCall;
if (found) {
@ -414,16 +390,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
// Custom (sub-agent / app-defined) message envelope. Reserved
// for future use; nothing renders today.
break;
case 'code-delta': {
params.onCodeDelta?.(event.delta);
break;
}
case 'config-updated':
case 'tool-updated': {
session.builderMutated = true;
params.onConfigUpdated?.();
break;
}
case 'error': {
session.errorEmitted = true;
dropOrphanMintedBubbles(session);
@ -451,14 +417,12 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
return undefined;
}
async function consumeStream(response: Response, session: StreamSession): Promise<boolean> {
if (!response.body) return false;
async function consumeStream(response: Response, session: StreamSession): Promise<void> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let doneSeen = false;
try {
readerLoop: while (true) {
const { done, value } = await reader.read();
@ -478,7 +442,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
}
const result = handleEvent(event, session);
if (result?.done) {
doneSeen = true;
break readerLoop;
}
}
@ -486,17 +449,12 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
} finally {
reader.releaseLock();
}
return doneSeen;
}
function finalizeStream(session: StreamSession): void {
for (const msg of session.minted) {
if (msg.status === CHAT_MESSAGE_STATUS.STREAMING) msg.status = CHAT_MESSAGE_STATUS.SUCCESS;
}
if (params.endpoint.value !== 'build') return;
if (session.builderMutated) params.onConfigUpdated?.();
}
async function postAndConsume(
@ -504,7 +462,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
body: Record<string, unknown>,
): Promise<{ ok: boolean }> {
const session: StreamSession = {
builderMutated: false,
errorEmitted: false,
minted: new Set(),
};
@ -513,7 +470,6 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
const controller = new AbortController();
abortController.value = controller;
let transportFailed = false;
let doneSeen = false;
try {
const browserId = localStorage.getItem('n8n-browserId') ?? '';
@ -537,7 +493,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
return { ok: false };
}
doneSeen = await consumeStream(response, session);
await consumeStream(response, session);
finalizeStream(session);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
@ -560,30 +516,24 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
isStreaming.value = false;
}
if (params.endpoint.value === 'build' && doneSeen) {
await nextTick();
params.onBuildDone?.();
}
return { ok: !transportFailed && !session.errorEmitted };
}
async function streamFromEndpoint(endpoint: 'build' | 'chat', message: string): Promise<void> {
async function streamChat(message: string): Promise<void> {
const { baseUrl } = rootStore.restApiContext;
const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/${endpoint}`;
const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/chat`;
const body: Record<string, unknown> = { message };
if (endpoint === 'chat' && params.continueSessionId?.value) {
if (params.continueSessionId?.value) {
body.sessionId = params.continueSessionId.value;
}
await postAndConsume(url, body);
}
/**
* Resume a suspended interaction. Build-mode interactions post to
* build/resume; preview chat approval prompts post to chat/resume. Both
* paths re-enter the same SSE handler. The `runId` is required it comes
* from the original `tool-call-suspended` chunk (live) or from the
* `openSuspensions` sidecar applied during history reload.
* Resume a suspended interaction via `chat/resume`, re-entering the same
* SSE handler. The `runId` is required it comes from the original
* `tool-call-suspended` chunk (live) or from the `openSuspensions` sidecar
* applied during history reload.
*
* The UI updates optimistically, then restores the previous card state if
* the resume POST or SSE stream fails.
@ -655,8 +605,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
}
const { baseUrl } = rootStore.restApiContext;
const resumeEndpoint = params.endpoint.value === 'chat' ? 'chat/resume' : 'build/resume';
const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/${resumeEndpoint}`;
const url = `${baseUrl}/projects/${params.projectId.value}/agents/v2/${params.agentId.value}/chat/resume`;
const { ok } = await postAndConsume(url, {
runId: payload.runId,
toolCallId: payload.toolCallId,
@ -704,7 +653,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
content: trimmed,
status: 'success',
});
await streamFromEndpoint(params.endpoint.value, trimmed);
await streamChat(trimmed);
}
function dismissFatalError(): void {

View File

@ -3,7 +3,6 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { AgentConfigFingerprint, AgentTelemetryStatus } from './agentTelemetry.utils';
export type AgentChatMode = 'build' | 'test';
export type AgentCreateSource = 'button' | 'dropdown' | 'card';
export type AgentConfigPart =
| 'instructions'
@ -40,13 +39,12 @@ export function useAgentTelemetry() {
function trackSubmittedMessage(params: {
agentId: string;
mode: AgentChatMode;
status: AgentTelemetryStatus;
agentConfig: AgentConfigFingerprint;
}) {
safeTrack('User submitted message to agent', {
agent_id: params.agentId,
mode: params.mode,
mode: 'test', // Constant dimension kept for warehouse-schema stability.
status: params.status,
agent_config: params.agentConfig,
...common(),

View File

@ -1,7 +1,6 @@
export const AGENTS_LIST_VIEW = 'AgentsListView';
export const AGENT_BUILDER_VIEW = 'AgentBuilderView';
export const AGENT_PREVIEW_VIEW = 'AgentPreviewView';
export const NEW_AGENT_VIEW = 'NewAgentView';
export const AGENT_VIEW = 'AgentView';
export const AGENT_SESSIONS_LIST_VIEW = 'AgentSessionsListView';
export const AGENT_SESSION_DETAIL_VIEW = 'AgentSessionDetailView';
@ -39,5 +38,3 @@ export {
/** Query-string key the builder uses to deep-link into a chat session. */
export const CONTINUE_SESSION_ID_PARAM = 'continueSessionId';
export { EXTENDED_PROMPT_MAX_LENGTH as AGENT_BUILDER_PROMPT_MAX_LENGTH } from '@/features/ai/shared/constants';

Some files were not shown because too many files have changed in this diff Show More