mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(ai-builder): Add code-base workflow builder (#24535)
Co-authored-by: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
ba48c922c7
commit
9729c2a5da
+2
-1
@@ -15,7 +15,8 @@
|
||||
"**/pnpm-lock.yaml",
|
||||
"**/CHANGELOG.md",
|
||||
"**/cl100k_base.json",
|
||||
"**/o200k_base.json"
|
||||
"**/o200k_base.json",
|
||||
"**/*.generated.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"sqlite3"
|
||||
],
|
||||
"overrides": {
|
||||
"@isaacs/brace-expansion": "5.0.1",
|
||||
"libphonenumber-js": "npm:[email protected]",
|
||||
"ast-types": "0.16.1",
|
||||
"@azure/identity": "4.13.0",
|
||||
|
||||
@@ -35,6 +35,7 @@ popd
|
||||
- **LLM key** (required for generation and any LLM-based evaluators):
|
||||
- `N8N_AI_ANTHROPIC_KEY` (see `evaluations/support/environment.ts`)
|
||||
- **Node definitions** (required for workflow generation, and used by evaluators):
|
||||
- export using `pnpm export:nodes` in this package.
|
||||
- `evaluations/.data/nodes.json` (see `evaluations/support/load-nodes.ts`)
|
||||
- Optional: `N8N_EVALS_DISABLED_NODES="n8n-nodes-base.httpRequest,..."` to exclude specific nodes from generation.
|
||||
- **LangSmith** (only for `--backend langsmith` runs):
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { parseEvaluationArgs } from '../cli/argument-parser';
|
||||
import { parseEvaluationArgs, type EvaluationSuite } from '../cli/argument-parser';
|
||||
|
||||
describe('argument-parser', () => {
|
||||
describe('suite options', () => {
|
||||
it('accepts all valid suite options', () => {
|
||||
const validSuites: EvaluationSuite[] = [
|
||||
'llm-judge',
|
||||
'pairwise',
|
||||
'programmatic',
|
||||
'similarity',
|
||||
];
|
||||
|
||||
for (const suite of validSuites) {
|
||||
const args = parseEvaluationArgs(['--suite', suite]);
|
||||
expect(args.suite).toBe(suite);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('parses numeric flags like --max-examples and --concurrency', () => {
|
||||
const args = parseEvaluationArgs([
|
||||
'--suite',
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ function createMockEvaluationResult(
|
||||
tools: { score: number; violations: Array<{ type: string; description: string }> };
|
||||
fromAi: { score: number; violations: Array<{ type: string; description: string }> };
|
||||
credentials: { score: number; violations: Array<{ type: string; description: string }> };
|
||||
graphValidation: { score: number; violations: Array<{ type: string; description: string }> };
|
||||
parameters: { score: number; violations: Array<{ type: string; description: string }> };
|
||||
similarity: { score: number; violations: Array<{ type: string; description: string }> } | null;
|
||||
}> = {},
|
||||
@@ -46,6 +47,7 @@ function createMockEvaluationResult(
|
||||
tools: { score: 1.0, violations: [] },
|
||||
fromAi: { score: 0.8, violations: [] },
|
||||
credentials: { score: 1.0, violations: [] },
|
||||
graphValidation: { score: 1.0, violations: [] },
|
||||
parameters: { score: 1.0, violations: [] },
|
||||
similarity: null,
|
||||
...overrides,
|
||||
|
||||
@@ -6,14 +6,16 @@ import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
|
||||
import type { LangsmithExampleFilters } from '../harness/harness-types';
|
||||
import { DEFAULTS } from '../support/constants';
|
||||
import type { StageModels } from '../support/environment.js';
|
||||
import type { StageModels } from '../support/environment';
|
||||
|
||||
export type EvaluationSuite = 'llm-judge' | 'pairwise' | 'programmatic' | 'similarity';
|
||||
export type EvaluationBackend = 'local' | 'langsmith';
|
||||
export type AgentType = 'multi-agent' | 'code-builder';
|
||||
|
||||
export interface EvaluationArgs {
|
||||
suite: EvaluationSuite;
|
||||
backend: EvaluationBackend;
|
||||
agent: AgentType;
|
||||
|
||||
verbose: boolean;
|
||||
repetitions: number;
|
||||
@@ -79,6 +81,7 @@ const cliSchema = z
|
||||
.object({
|
||||
suite: z.enum(['llm-judge', 'pairwise', 'programmatic', 'similarity']).default('llm-judge'),
|
||||
backend: z.enum(['local', 'langsmith']).default('local'),
|
||||
agent: z.enum(['code-builder', 'multi-agent']).default('code-builder'),
|
||||
|
||||
verbose: z.boolean().default(false),
|
||||
repetitions: z.coerce.number().int().positive().default(DEFAULTS.REPETITIONS),
|
||||
@@ -152,6 +155,12 @@ const FLAG_DEFS: Record<string, FlagDef> = {
|
||||
desc: 'Evaluation suite (llm-judge|pairwise|programmatic|similarity)',
|
||||
},
|
||||
'--backend': { key: 'backend', kind: 'string', group: 'eval', desc: 'Backend (local|langsmith)' },
|
||||
'--agent': {
|
||||
key: 'agent',
|
||||
kind: 'string',
|
||||
group: 'eval',
|
||||
desc: 'Agent type (code-builder|multi-agent)',
|
||||
},
|
||||
'--max-examples': {
|
||||
key: 'maxExamples',
|
||||
kind: 'string',
|
||||
@@ -529,6 +538,7 @@ export function parseEvaluationArgs(argv: string[] = process.argv.slice(2)): Eva
|
||||
return {
|
||||
suite: parsed.suite,
|
||||
backend: parsed.backend,
|
||||
agent: parsed.agent,
|
||||
verbose: parsed.verbose,
|
||||
repetitions: parsed.repetitions,
|
||||
concurrency: parsed.concurrency,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parse } from 'csv-parse/sync';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import type { TestCase } from '../harness/harness-types.js';
|
||||
import type { TestCase } from '../harness/harness-types';
|
||||
|
||||
/** Path to the default prompts CSV fixture */
|
||||
const DEFAULT_PROMPTS_PATH = join(__dirname, '..', 'fixtures', 'default-prompts.csv');
|
||||
|
||||
@@ -8,10 +8,23 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
import { CodeWorkflowBuilder } from '@/code-builder';
|
||||
import type { CoordinationLogEntry } from '@/types/coordination';
|
||||
import type { StreamChunk, WorkflowUpdateChunk } from '@/types/streaming';
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
|
||||
/** Type guard for SimpleWorkflow */
|
||||
function isSimpleWorkflow(value: unknown): value is SimpleWorkflow {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'name' in value &&
|
||||
'nodes' in value &&
|
||||
'connections' in value
|
||||
);
|
||||
}
|
||||
|
||||
import {
|
||||
argsToStageModels,
|
||||
getDefaultDatasetName,
|
||||
@@ -25,6 +38,7 @@ import {
|
||||
getDefaultTestCaseIds,
|
||||
} from './csv-prompt-loader';
|
||||
import { sendWebhookNotification } from './webhook';
|
||||
import { WorkflowGenerationError } from '../errors';
|
||||
import {
|
||||
consumeGenerator,
|
||||
extractSubgraphMetrics,
|
||||
@@ -44,11 +58,19 @@ import {
|
||||
type TestCase,
|
||||
type Evaluator,
|
||||
type EvaluationContext,
|
||||
type GenerationResult,
|
||||
} from '../index';
|
||||
import { generateRunId, isWorkflowStateValues } from '../langsmith/types';
|
||||
import { EVAL_TYPES, EVAL_USERS } from '../support/constants';
|
||||
import { AGENT_TYPES, EVAL_TYPES, EVAL_USERS } from '../support/constants';
|
||||
import { setupTestEnvironment, createAgent, type ResolvedStageLLMs } from '../support/environment';
|
||||
|
||||
/**
|
||||
* Type guard for workflow update chunks from streaming output.
|
||||
*/
|
||||
function isWorkflowUpdateChunk(chunk: StreamChunk): chunk is WorkflowUpdateChunk {
|
||||
return chunk.type === 'workflow-updated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if state values contain a coordination log.
|
||||
*/
|
||||
@@ -61,7 +83,7 @@ function hasCoordinationLog(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow generator function.
|
||||
* Create a workflow generator function for the multi-agent system.
|
||||
* LangSmith tracing is handled via traceable() in the runner.
|
||||
* Callbacks are passed explicitly from the runner to ensure correct trace context
|
||||
* under high concurrency (avoids AsyncLocalStorage race conditions).
|
||||
@@ -136,6 +158,99 @@ function createWorkflowGenerator(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CodeWorkflowBuilder generator function.
|
||||
* Uses the CodeWorkflowBuilder which coordinates planning and coding agents to generate
|
||||
* workflows via TypeScript SDK code and emits workflow JSON directly in the stream.
|
||||
* Returns GenerationResult including the source code for artifact saving.
|
||||
*
|
||||
* @param timeoutMs - Optional timeout in milliseconds. When provided, the agent will be
|
||||
* aborted if it exceeds this duration. This ensures the generator
|
||||
* actually stops instead of continuing to run after timeout rejection.
|
||||
*/
|
||||
function createCodeWorkflowBuilderGenerator(
|
||||
parsedNodeTypes: INodeTypeDescription[],
|
||||
llms: ResolvedStageLLMs,
|
||||
timeoutMs?: number,
|
||||
nodeDefinitionDirs?: string[],
|
||||
): (prompt: string, collectors?: GenerationCollectors) => Promise<GenerationResult> {
|
||||
// Subgraph metrics are not applicable since CodeWorkflowBuilder doesn't use coordination logs.
|
||||
return async (prompt: string, collectors?: GenerationCollectors): Promise<GenerationResult> => {
|
||||
const runId = generateRunId();
|
||||
|
||||
// Accumulate token usage across all LLM calls
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
const builder = new CodeWorkflowBuilder({
|
||||
llm: llms.builder,
|
||||
nodeTypes: parsedNodeTypes,
|
||||
nodeDefinitionDirs,
|
||||
onTokenUsage: collectors?.tokenUsage
|
||||
? (usage) => {
|
||||
totalInputTokens += usage.inputTokens;
|
||||
totalOutputTokens += usage.outputTokens;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const payload = getChatPayload({
|
||||
evalType: EVAL_TYPES.LANGSMITH,
|
||||
message: prompt,
|
||||
workflowId: runId,
|
||||
featureFlags: { codeBuilder: true },
|
||||
});
|
||||
|
||||
let workflow: SimpleWorkflow | null = null;
|
||||
let generatedCode: string | undefined;
|
||||
|
||||
// Create an AbortController to properly cancel the agent on timeout or error.
|
||||
// Without this, the agent continues running even after Promise.race rejects,
|
||||
// causing the full timeout duration to elapse before the error surfaces.
|
||||
const abortController = new AbortController();
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
if (timeoutMs !== undefined && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
abortController.abort(new Error(`CodeWorkflowBuilder timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const output of builder.chat(
|
||||
payload,
|
||||
EVAL_USERS.LANGSMITH,
|
||||
abortController.signal,
|
||||
)) {
|
||||
for (const message of output.messages) {
|
||||
if (isWorkflowUpdateChunk(message)) {
|
||||
const parsed: unknown = JSON.parse(message.codeSnippet);
|
||||
if (isSimpleWorkflow(parsed)) {
|
||||
workflow = parsed;
|
||||
generatedCode = message.sourceCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflow) {
|
||||
throw new WorkflowGenerationError('CodeWorkflowBuilder did not produce a workflow');
|
||||
}
|
||||
|
||||
// Report accumulated token usage
|
||||
if (collectors?.tokenUsage && (totalInputTokens > 0 || totalOutputTokens > 0)) {
|
||||
collectors.tokenUsage({ inputTokens: totalInputTokens, outputTokens: totalOutputTokens });
|
||||
}
|
||||
|
||||
return { workflow, generatedCode };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load test cases from various sources.
|
||||
*/
|
||||
@@ -208,14 +323,20 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
throw new Error('LangSmith client not initialized - check LANGSMITH_API_KEY');
|
||||
}
|
||||
|
||||
// Create workflow generator with per-stage LLMs
|
||||
const generateWorkflow = createWorkflowGenerator(
|
||||
env.parsedNodeTypes,
|
||||
env.llms,
|
||||
args.featureFlags,
|
||||
);
|
||||
// Create workflow generator based on agent type
|
||||
// CODE_BUILDER uses CodeWorkflowBuilder
|
||||
const generateWorkflow =
|
||||
args.agent === AGENT_TYPES.CODE_BUILDER
|
||||
? createCodeWorkflowBuilderGenerator(
|
||||
env.parsedNodeTypes,
|
||||
env.llms,
|
||||
args.timeoutMs,
|
||||
env.nodeDefinitionDirs,
|
||||
)
|
||||
: createWorkflowGenerator(env.parsedNodeTypes, env.llms, args.featureFlags);
|
||||
|
||||
// Create evaluators based on mode (using judge LLM for evaluation)
|
||||
// Create evaluators based on suite (using judge LLM for evaluation)
|
||||
// The --suite flag is always respected, regardless of agent type
|
||||
const evaluators: Array<Evaluator<EvaluationContext>> = [];
|
||||
|
||||
switch (args.suite) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Custom error class for workflow generation failures that preserves captured logs.
|
||||
* When the code agent fails to produce a workflow, this error carries the logs
|
||||
* that were captured during execution, allowing them to be saved to disk for debugging.
|
||||
*/
|
||||
export class WorkflowGenerationError extends Error {
|
||||
readonly logs?: string;
|
||||
|
||||
constructor(message: string, logs?: string) {
|
||||
super(message);
|
||||
this.name = 'WorkflowGenerationError';
|
||||
this.logs = logs;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export function createProgrammaticEvaluator(
|
||||
userPrompt: ctx.prompt,
|
||||
generatedWorkflow: workflow,
|
||||
referenceWorkflows: ctx.referenceWorkflows,
|
||||
generatedCode: ctx.generatedCode,
|
||||
},
|
||||
nodeTypes,
|
||||
);
|
||||
@@ -77,6 +78,12 @@ export function createProgrammaticEvaluator(
|
||||
'metric',
|
||||
formatViolations(result.credentials.violations),
|
||||
),
|
||||
fb(
|
||||
'graphValidation',
|
||||
result.graphValidation.score,
|
||||
'metric',
|
||||
formatViolations(result.graphValidation.violations),
|
||||
),
|
||||
fb(
|
||||
'parameters',
|
||||
result.parameters.score,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Client as LangsmithClient } from 'langsmith/client';
|
||||
import type pLimit from 'p-limit';
|
||||
|
||||
import type { EvalLogger } from './logger.js';
|
||||
import type { GenerationCollectors } from './runner.js';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow.js';
|
||||
import type { EvalLogger } from './logger';
|
||||
import type { GenerationCollectors } from './runner';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
export type LlmCallLimiter = ReturnType<typeof pLimit>;
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface EvaluationContext {
|
||||
* Note: timeouts are best-effort unless underlying calls support cancellation (AbortSignal).
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Generated TypeScript SDK code for code-level evaluators.
|
||||
* Populated from GenerationResult when available.
|
||||
*/
|
||||
generatedCode?: string;
|
||||
}
|
||||
|
||||
/** Context attached to an individual test case (prompt is provided separately). */
|
||||
@@ -103,8 +108,11 @@ export type EvaluationSuite = 'llm-judge' | 'pairwise' | 'programmatic' | 'simil
|
||||
* Configuration for an evaluation run.
|
||||
*/
|
||||
export interface RunConfigBase {
|
||||
/** Function to generate workflow from prompt. Optional collectors receive metrics. */
|
||||
generateWorkflow: (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow>;
|
||||
/** Function to generate workflow from prompt. May return GenerationResult with source code. Optional collectors receive metrics. */
|
||||
generateWorkflow: (
|
||||
prompt: string,
|
||||
collectors?: GenerationCollectors,
|
||||
) => Promise<SimpleWorkflow | GenerationResult>;
|
||||
/** Evaluators to run on each generated workflow */
|
||||
evaluators: Array<Evaluator<EvaluationContext>>;
|
||||
/** Global context available to all evaluators */
|
||||
@@ -207,9 +215,35 @@ export interface ExampleResult {
|
||||
/** Subgraph timing and workflow metrics */
|
||||
subgraphMetrics?: SubgraphMetrics;
|
||||
workflow?: SimpleWorkflow;
|
||||
/** Generated source code (e.g., TypeScript SDK code from coding agent) */
|
||||
generatedCode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from workflow generation that may include source code.
|
||||
* Used by generators that produce code (e.g., coding agent).
|
||||
*/
|
||||
export interface GenerationResult {
|
||||
workflow: SimpleWorkflow;
|
||||
/** Source code that generated the workflow (e.g., TypeScript SDK code) */
|
||||
generatedCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a generation result is a GenerationResult object.
|
||||
*/
|
||||
export function isGenerationResult(
|
||||
value: SimpleWorkflow | GenerationResult,
|
||||
): value is GenerationResult {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'workflow' in value &&
|
||||
typeof value.workflow === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of an entire evaluation run.
|
||||
*/
|
||||
|
||||
@@ -7,10 +7,10 @@ import type {
|
||||
Feedback,
|
||||
ExampleResult,
|
||||
RunSummary,
|
||||
} from './harness-types.js';
|
||||
import type { EvalLogger } from './logger.js';
|
||||
} from './harness-types';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { groupByEvaluator, selectScoringItems, calculateFiniteAverage } from './score-calculator';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow.js';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
/**
|
||||
* Truncate a string for display.
|
||||
|
||||
@@ -9,10 +9,10 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { feedbackKey } from './feedback';
|
||||
import type { ExampleResult, Feedback, RunSummary } from './harness-types.js';
|
||||
import type { EvalLogger } from './logger.js';
|
||||
import type { ExampleResult, Feedback, RunSummary } from './harness-types';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { selectScoringItems, calculateFiniteAverage } from './score-calculator';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow.js';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
|
||||
/**
|
||||
* Interface for saving evaluation artifacts to disk.
|
||||
@@ -76,6 +76,11 @@ export function createArtifactSaver(options: ArtifactSaverOptions): ArtifactSave
|
||||
);
|
||||
}
|
||||
|
||||
// Save generated code if available (e.g., TypeScript SDK code from coding agent)
|
||||
if (result.generatedCode) {
|
||||
fs.writeFileSync(path.join(exampleDir, 'code.ts'), result.generatedCode, 'utf-8');
|
||||
}
|
||||
|
||||
// Save feedback
|
||||
const feedbackOutput = formatFeedbackForExport(result);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -6,22 +6,24 @@ import pLimit from 'p-limit';
|
||||
|
||||
import { runWithOptionalLimiter, withTimeout } from './evaluation-helpers';
|
||||
import { toLangsmithEvaluationResult } from './feedback';
|
||||
import type {
|
||||
Evaluator,
|
||||
TestCase,
|
||||
EvaluationContext,
|
||||
GlobalRunContext,
|
||||
TestCaseContext,
|
||||
Feedback,
|
||||
RunConfig,
|
||||
LocalRunConfig,
|
||||
LangsmithRunConfig,
|
||||
ExampleResult,
|
||||
RunSummary,
|
||||
EvaluationLifecycle,
|
||||
LangsmithExampleFilters,
|
||||
LlmCallLimiter,
|
||||
} from './harness-types.js';
|
||||
import {
|
||||
isGenerationResult,
|
||||
type Evaluator,
|
||||
type TestCase,
|
||||
type EvaluationContext,
|
||||
type GlobalRunContext,
|
||||
type TestCaseContext,
|
||||
type Feedback,
|
||||
type RunConfig,
|
||||
type LocalRunConfig,
|
||||
type LangsmithRunConfig,
|
||||
type ExampleResult,
|
||||
type RunSummary,
|
||||
type EvaluationLifecycle,
|
||||
type LangsmithExampleFilters,
|
||||
type LlmCallLimiter,
|
||||
type GenerationResult,
|
||||
} from './harness-types';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { createArtifactSaver, type ArtifactSaver } from './output';
|
||||
import {
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
selectScoringItems,
|
||||
calculateFiniteAverage,
|
||||
} from './score-calculator';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow.js';
|
||||
import type { SimpleWorkflow } from '../../src/types/workflow';
|
||||
import { extractMessageContent } from '../langsmith/types';
|
||||
|
||||
const DEFAULT_PASS_THRESHOLD = 0.7;
|
||||
@@ -188,14 +190,16 @@ function buildContext(args: {
|
||||
globalContext?: GlobalRunContext;
|
||||
testCaseContext?: TestCaseContext;
|
||||
referenceWorkflows?: SimpleWorkflow[];
|
||||
generatedCode?: string;
|
||||
}): EvaluationContext {
|
||||
const { prompt, globalContext, testCaseContext, referenceWorkflows } = args;
|
||||
const { prompt, globalContext, testCaseContext, referenceWorkflows, generatedCode } = args;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
...(globalContext ?? {}),
|
||||
...(testCaseContext ?? {}),
|
||||
...(referenceWorkflows?.length ? { referenceWorkflows } : {}),
|
||||
...(generatedCode ? { generatedCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -435,11 +439,171 @@ function extractContextFromLangsmithInputs(inputs: unknown): TestCaseContext {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collected metrics from workflow generation.
|
||||
*/
|
||||
interface CollectedMetrics {
|
||||
genInputTokens?: number;
|
||||
genOutputTokens?: number;
|
||||
discoveryDurationMs?: number;
|
||||
builderDurationMs?: number;
|
||||
responderDurationMs?: number;
|
||||
nodeCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collectors for workflow generation metrics.
|
||||
* Returns collectors and a function to get the collected values.
|
||||
*/
|
||||
function createMetricsCollectors(): {
|
||||
collectors: GenerationCollectors;
|
||||
getMetrics: () => CollectedMetrics;
|
||||
} {
|
||||
const metrics: CollectedMetrics = {};
|
||||
|
||||
const collectors: GenerationCollectors = {
|
||||
tokenUsage: (usage) => {
|
||||
metrics.genInputTokens = usage.inputTokens;
|
||||
metrics.genOutputTokens = usage.outputTokens;
|
||||
},
|
||||
subgraphMetrics: (m) => {
|
||||
metrics.discoveryDurationMs = m.discoveryDurationMs;
|
||||
metrics.builderDurationMs = m.builderDurationMs;
|
||||
metrics.responderDurationMs = m.responderDurationMs;
|
||||
metrics.nodeCount = m.nodeCount;
|
||||
},
|
||||
};
|
||||
|
||||
return { collectors, getMetrics: () => metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SubgraphMetrics object if any metrics are present, otherwise undefined.
|
||||
*/
|
||||
function buildSubgraphMetrics(metrics: CollectedMetrics): ExampleResult['subgraphMetrics'] {
|
||||
const { discoveryDurationMs, builderDurationMs, responderDurationMs, nodeCount } = metrics;
|
||||
const hasMetrics =
|
||||
discoveryDurationMs !== undefined ||
|
||||
builderDurationMs !== undefined ||
|
||||
responderDurationMs !== undefined ||
|
||||
nodeCount !== undefined;
|
||||
|
||||
return hasMetrics
|
||||
? { discoveryDurationMs, builderDurationMs, responderDurationMs, nodeCount }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error feedback array.
|
||||
*/
|
||||
function createErrorFeedback(errorMessage: string): Feedback[] {
|
||||
return [
|
||||
{
|
||||
evaluator: 'runner',
|
||||
metric: 'error',
|
||||
score: 0,
|
||||
kind: 'score',
|
||||
comment: errorMessage,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the success path for a local example (generation + evaluation).
|
||||
* Extracted to reduce complexity of runLocalExample.
|
||||
*/
|
||||
async function runLocalExampleSuccess(args: {
|
||||
index: number;
|
||||
startTime: number;
|
||||
testCase: TestCase;
|
||||
generateWorkflow: (
|
||||
prompt: string,
|
||||
collectors?: GenerationCollectors,
|
||||
) => Promise<SimpleWorkflow | GenerationResult>;
|
||||
evaluators: Array<Evaluator<EvaluationContext>>;
|
||||
globalContext?: GlobalRunContext;
|
||||
passThreshold: number;
|
||||
timeoutMs: number | undefined;
|
||||
lifecycle?: Partial<EvaluationLifecycle>;
|
||||
}): Promise<ExampleResult> {
|
||||
const {
|
||||
index,
|
||||
startTime,
|
||||
testCase,
|
||||
generateWorkflow,
|
||||
evaluators,
|
||||
globalContext,
|
||||
passThreshold,
|
||||
timeoutMs,
|
||||
lifecycle,
|
||||
} = args;
|
||||
|
||||
// Generate workflow with metrics collection
|
||||
const genStartTime = Date.now();
|
||||
const { collectors, getMetrics } = createMetricsCollectors();
|
||||
|
||||
const genResult = await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: generateWorkflow(testCase.prompt, collectors),
|
||||
timeoutMs,
|
||||
label: 'workflow_generation',
|
||||
});
|
||||
}, globalContext?.llmCallLimiter);
|
||||
const genDurationMs = Date.now() - genStartTime;
|
||||
|
||||
// Extract workflow and optional generated code
|
||||
const workflow = isGenerationResult(genResult) ? genResult.workflow : genResult;
|
||||
const generatedCode = isGenerationResult(genResult) ? genResult.generatedCode : undefined;
|
||||
|
||||
lifecycle?.onWorkflowGenerated?.(workflow, genDurationMs);
|
||||
|
||||
const context = buildContext({
|
||||
prompt: testCase.prompt,
|
||||
globalContext: {
|
||||
...(globalContext ?? {}),
|
||||
timeoutMs,
|
||||
},
|
||||
testCaseContext: testCase.context,
|
||||
referenceWorkflows: testCase.referenceWorkflows,
|
||||
generatedCode,
|
||||
});
|
||||
|
||||
// Run evaluators in parallel
|
||||
const evalStartTime = Date.now();
|
||||
const feedback = await evaluateWithPlugins(workflow, evaluators, context, timeoutMs, lifecycle);
|
||||
const evalDurationMs = Date.now() - evalStartTime;
|
||||
|
||||
// Calculate result
|
||||
const score = calculateExampleScore(feedback);
|
||||
const status = hasErrorFeedback(feedback) ? 'error' : determineStatus({ score, passThreshold });
|
||||
const durationMs = Date.now() - startTime;
|
||||
const metrics = getMetrics();
|
||||
|
||||
return {
|
||||
index,
|
||||
prompt: testCase.prompt,
|
||||
status,
|
||||
score,
|
||||
feedback,
|
||||
durationMs,
|
||||
generationDurationMs: genDurationMs,
|
||||
evaluationDurationMs: evalDurationMs,
|
||||
generationInputTokens: metrics.genInputTokens,
|
||||
generationOutputTokens: metrics.genOutputTokens,
|
||||
subgraphMetrics: buildSubgraphMetrics(metrics),
|
||||
workflow,
|
||||
generatedCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function runLocalExample(args: {
|
||||
index: number;
|
||||
total: number;
|
||||
testCase: TestCase;
|
||||
generateWorkflow: (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow>;
|
||||
generateWorkflow: (
|
||||
prompt: string,
|
||||
collectors?: GenerationCollectors,
|
||||
) => Promise<SimpleWorkflow | GenerationResult>;
|
||||
evaluators: Array<Evaluator<EvaluationContext>>;
|
||||
globalContext?: GlobalRunContext;
|
||||
passThreshold: number;
|
||||
@@ -464,78 +628,17 @@ async function runLocalExample(args: {
|
||||
lifecycle?.onExampleStart?.(index, total, testCase.prompt);
|
||||
|
||||
try {
|
||||
// Generate workflow with metrics collection
|
||||
const genStartTime = Date.now();
|
||||
let genInputTokens: number | undefined;
|
||||
let genOutputTokens: number | undefined;
|
||||
let discoveryDurationMs: number | undefined;
|
||||
let builderDurationMs: number | undefined;
|
||||
let responderDurationMs: number | undefined;
|
||||
let nodeCount: number | undefined;
|
||||
|
||||
const collectors: GenerationCollectors = {
|
||||
tokenUsage: (usage) => {
|
||||
genInputTokens = usage.inputTokens;
|
||||
genOutputTokens = usage.outputTokens;
|
||||
},
|
||||
subgraphMetrics: (metrics) => {
|
||||
discoveryDurationMs = metrics.discoveryDurationMs;
|
||||
builderDurationMs = metrics.builderDurationMs;
|
||||
responderDurationMs = metrics.responderDurationMs;
|
||||
nodeCount = metrics.nodeCount;
|
||||
},
|
||||
};
|
||||
|
||||
const workflow = await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: generateWorkflow(testCase.prompt, collectors),
|
||||
timeoutMs,
|
||||
label: 'workflow_generation',
|
||||
});
|
||||
}, globalContext?.llmCallLimiter);
|
||||
const genDurationMs = Date.now() - genStartTime;
|
||||
lifecycle?.onWorkflowGenerated?.(workflow, genDurationMs);
|
||||
|
||||
const context = buildContext({
|
||||
prompt: testCase.prompt,
|
||||
globalContext: {
|
||||
...(globalContext ?? {}),
|
||||
timeoutMs,
|
||||
},
|
||||
testCaseContext: testCase.context,
|
||||
referenceWorkflows: testCase.referenceWorkflows,
|
||||
});
|
||||
|
||||
// Run evaluators in parallel
|
||||
const evalStartTime = Date.now();
|
||||
const feedback = await evaluateWithPlugins(workflow, evaluators, context, timeoutMs, lifecycle);
|
||||
const evalDurationMs = Date.now() - evalStartTime;
|
||||
|
||||
// Calculate result
|
||||
const score = calculateExampleScore(feedback);
|
||||
const status = hasErrorFeedback(feedback) ? 'error' : determineStatus({ score, passThreshold });
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
const result: ExampleResult = {
|
||||
const result = await runLocalExampleSuccess({
|
||||
index,
|
||||
prompt: testCase.prompt,
|
||||
status,
|
||||
score,
|
||||
feedback,
|
||||
durationMs,
|
||||
generationDurationMs: genDurationMs,
|
||||
evaluationDurationMs: evalDurationMs,
|
||||
generationInputTokens: genInputTokens,
|
||||
generationOutputTokens: genOutputTokens,
|
||||
subgraphMetrics:
|
||||
discoveryDurationMs !== undefined ||
|
||||
builderDurationMs !== undefined ||
|
||||
responderDurationMs !== undefined ||
|
||||
nodeCount !== undefined
|
||||
? { discoveryDurationMs, builderDurationMs, responderDurationMs, nodeCount }
|
||||
: undefined,
|
||||
workflow,
|
||||
};
|
||||
startTime,
|
||||
testCase,
|
||||
generateWorkflow,
|
||||
evaluators,
|
||||
globalContext,
|
||||
passThreshold,
|
||||
timeoutMs,
|
||||
lifecycle,
|
||||
});
|
||||
|
||||
artifactSaver?.saveExample(result);
|
||||
lifecycle?.onExampleComplete?.(index, result);
|
||||
@@ -548,15 +651,7 @@ async function runLocalExample(args: {
|
||||
prompt: testCase.prompt,
|
||||
status: 'error',
|
||||
score: 0,
|
||||
feedback: [
|
||||
{
|
||||
evaluator: 'runner',
|
||||
metric: 'error',
|
||||
score: 0,
|
||||
kind: 'score',
|
||||
comment: errorMessage,
|
||||
},
|
||||
],
|
||||
feedback: createErrorFeedback(errorMessage),
|
||||
durationMs,
|
||||
error: errorMessage,
|
||||
};
|
||||
@@ -581,7 +676,10 @@ function createArtifactSaverIfRequested(args: {
|
||||
|
||||
async function runLocalDataset(params: {
|
||||
testCases: TestCase[];
|
||||
generateWorkflow: (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow>;
|
||||
generateWorkflow: (
|
||||
prompt: string,
|
||||
collectors?: GenerationCollectors,
|
||||
) => Promise<SimpleWorkflow | GenerationResult>;
|
||||
evaluators: Array<Evaluator<EvaluationContext>>;
|
||||
globalContext?: GlobalRunContext;
|
||||
passThreshold: number;
|
||||
@@ -985,6 +1083,36 @@ async function runLangsmithEvaluateAndFlush(params: {
|
||||
return { experimentName, experimentId, datasetId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats tracker for LangSmith evaluation.
|
||||
*/
|
||||
interface LangsmithStats {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
errors: number;
|
||||
scoreSum: number;
|
||||
durationSumMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update stats based on example result status.
|
||||
*/
|
||||
function updateStats(
|
||||
stats: LangsmithStats,
|
||||
status: 'pass' | 'fail' | 'error',
|
||||
score: number,
|
||||
durationMs: number,
|
||||
): void {
|
||||
stats.total++;
|
||||
stats.scoreSum += score;
|
||||
stats.durationSumMs += durationMs;
|
||||
|
||||
if (status === 'pass') stats.passed++;
|
||||
else if (status === 'fail') stats.failed++;
|
||||
else stats.errors++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run evaluation in LangSmith mode.
|
||||
*/
|
||||
@@ -1026,11 +1154,14 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
const traceableGenerateWorkflow = traceable(
|
||||
async (args: {
|
||||
prompt: string;
|
||||
genFn: (prompt: string, collectors?: GenerationCollectors) => Promise<SimpleWorkflow>;
|
||||
genFn: (
|
||||
prompt: string,
|
||||
collectors?: GenerationCollectors,
|
||||
) => Promise<SimpleWorkflow | GenerationResult>;
|
||||
collectors?: GenerationCollectors;
|
||||
limiter?: LlmCallLimiter;
|
||||
genTimeoutMs?: number;
|
||||
}): Promise<SimpleWorkflow> => {
|
||||
}): Promise<SimpleWorkflow | GenerationResult> => {
|
||||
return await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: args.genFn(args.prompt, args.collectors),
|
||||
@@ -1050,7 +1181,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
// NOTE: Do NOT wrap target with traceable() - evaluate() handles tracing automatically.
|
||||
let targetCallCount = 0;
|
||||
let totalExamples = 0;
|
||||
const stats = {
|
||||
const stats: LangsmithStats = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
@@ -1068,30 +1199,10 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
lifecycle?.onExampleStart?.(index, totalExamples, prompt);
|
||||
const startTime = Date.now();
|
||||
const genStart = Date.now();
|
||||
|
||||
// Metrics collection for this example
|
||||
let genInputTokens: number | undefined;
|
||||
let genOutputTokens: number | undefined;
|
||||
let discoveryDurationMs: number | undefined;
|
||||
let builderDurationMs: number | undefined;
|
||||
let responderDurationMs: number | undefined;
|
||||
let nodeCount: number | undefined;
|
||||
|
||||
const collectors: GenerationCollectors = {
|
||||
tokenUsage: (usage) => {
|
||||
genInputTokens = usage.inputTokens;
|
||||
genOutputTokens = usage.outputTokens;
|
||||
},
|
||||
subgraphMetrics: (metrics) => {
|
||||
discoveryDurationMs = metrics.discoveryDurationMs;
|
||||
builderDurationMs = metrics.builderDurationMs;
|
||||
responderDurationMs = metrics.responderDurationMs;
|
||||
nodeCount = metrics.nodeCount;
|
||||
},
|
||||
};
|
||||
const { collectors, getMetrics } = createMetricsCollectors();
|
||||
|
||||
try {
|
||||
const workflow = await traceableGenerateWorkflow({
|
||||
const genResult = await traceableGenerateWorkflow({
|
||||
prompt,
|
||||
genFn: generateWorkflow,
|
||||
collectors,
|
||||
@@ -1099,6 +1210,11 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
genTimeoutMs: timeoutMs,
|
||||
});
|
||||
const genDurationMs = Date.now() - genStart;
|
||||
|
||||
// Extract workflow and optional generated code
|
||||
const workflow = isGenerationResult(genResult) ? genResult.workflow : genResult;
|
||||
const generatedCode = isGenerationResult(genResult) ? genResult.generatedCode : undefined;
|
||||
|
||||
lifecycle?.onWorkflowGenerated?.(workflow, genDurationMs);
|
||||
|
||||
const extracted = extractContextFromLangsmithInputs({
|
||||
@@ -1109,6 +1225,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
prompt,
|
||||
globalContext: effectiveGlobalContext,
|
||||
testCaseContext: extracted,
|
||||
generatedCode,
|
||||
});
|
||||
|
||||
// Run all evaluators in parallel
|
||||
@@ -1128,13 +1245,8 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
? 'error'
|
||||
: determineStatus({ score, passThreshold });
|
||||
|
||||
stats.total++;
|
||||
stats.scoreSum += score;
|
||||
stats.durationSumMs += totalDurationMs;
|
||||
|
||||
if (status === 'pass') stats.passed++;
|
||||
else if (status === 'fail') stats.failed++;
|
||||
else stats.errors++;
|
||||
updateStats(stats, status, score, totalDurationMs);
|
||||
const metrics = getMetrics();
|
||||
|
||||
const result: ExampleResult = {
|
||||
index,
|
||||
@@ -1145,16 +1257,11 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
durationMs: totalDurationMs,
|
||||
generationDurationMs: genDurationMs,
|
||||
evaluationDurationMs: evalDurationMs,
|
||||
generationInputTokens: genInputTokens,
|
||||
generationOutputTokens: genOutputTokens,
|
||||
subgraphMetrics:
|
||||
discoveryDurationMs !== undefined ||
|
||||
builderDurationMs !== undefined ||
|
||||
responderDurationMs !== undefined ||
|
||||
nodeCount !== undefined
|
||||
? { discoveryDurationMs, builderDurationMs, responderDurationMs, nodeCount }
|
||||
: undefined,
|
||||
generationInputTokens: metrics.genInputTokens,
|
||||
generationOutputTokens: metrics.genOutputTokens,
|
||||
subgraphMetrics: buildSubgraphMetrics(metrics),
|
||||
workflow,
|
||||
generatedCode,
|
||||
};
|
||||
|
||||
artifactSaver?.saveExample(result);
|
||||
@@ -1162,12 +1269,7 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
lifecycle?.onExampleComplete?.(index, result);
|
||||
|
||||
// Create metrics feedback for LangSmith (subgraph timing + node count)
|
||||
const metricsFeedback = createMetricsFeedback({
|
||||
discoveryDurationMs,
|
||||
builderDurationMs,
|
||||
responderDurationMs,
|
||||
nodeCount,
|
||||
});
|
||||
const metricsFeedback = createMetricsFeedback(metrics);
|
||||
|
||||
return {
|
||||
workflow,
|
||||
@@ -1178,21 +1280,12 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const workflow: SimpleWorkflow = { name: 'Evaluation Error', nodes: [], connections: {} };
|
||||
const feedback: Feedback[] = [
|
||||
{
|
||||
evaluator: 'runner',
|
||||
metric: 'error',
|
||||
score: 0,
|
||||
kind: 'score',
|
||||
comment: errorMessage,
|
||||
},
|
||||
];
|
||||
const feedback = createErrorFeedback(errorMessage);
|
||||
|
||||
const totalDurationMs = Date.now() - startTime;
|
||||
const genDurationMs = Date.now() - genStart;
|
||||
stats.total++;
|
||||
stats.errors++;
|
||||
stats.durationSumMs += totalDurationMs;
|
||||
updateStats(stats, 'error', 0, totalDurationMs);
|
||||
|
||||
const result: ExampleResult = {
|
||||
index,
|
||||
prompt,
|
||||
|
||||
+4
-105
@@ -1,105 +1,4 @@
|
||||
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
|
||||
/**
|
||||
* Accumulated token usage from all LLM calls.
|
||||
*/
|
||||
export interface AccumulatedTokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a record (plain object).
|
||||
*/
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract input tokens from a usage object.
|
||||
* Supports both Anthropic (input_tokens) and OpenAI (prompt_tokens) formats.
|
||||
*/
|
||||
function extractInputTokens(usage: Record<string, unknown>): number {
|
||||
// Anthropic format
|
||||
if (typeof usage.input_tokens === 'number') {
|
||||
return usage.input_tokens;
|
||||
}
|
||||
// OpenAI format
|
||||
if (typeof usage.prompt_tokens === 'number') {
|
||||
return usage.prompt_tokens;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract output tokens from a usage object.
|
||||
* Supports both Anthropic (output_tokens) and OpenAI (completion_tokens) formats.
|
||||
*/
|
||||
function extractOutputTokens(usage: Record<string, unknown>): number {
|
||||
// Anthropic format
|
||||
if (typeof usage.output_tokens === 'number') {
|
||||
return usage.output_tokens;
|
||||
}
|
||||
// OpenAI format
|
||||
if (typeof usage.completion_tokens === 'number') {
|
||||
return usage.completion_tokens;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback handler that tracks token usage from all LLM calls.
|
||||
* Used during evaluation to capture total generation costs across all agents
|
||||
* (supervisor, discovery, builder, responder).
|
||||
*
|
||||
* Token usage is extracted from LLM response metadata, supporting:
|
||||
* - Anthropic: input_tokens, output_tokens
|
||||
* - OpenAI: prompt_tokens, completion_tokens
|
||||
*/
|
||||
export class TokenUsageTrackingHandler extends BaseCallbackHandler {
|
||||
name = 'TokenUsageTrackingHandler';
|
||||
|
||||
private totalInputTokens = 0;
|
||||
private totalOutputTokens = 0;
|
||||
|
||||
/**
|
||||
* Called when an LLM call completes.
|
||||
* Extracts and accumulates token usage from the response.
|
||||
*/
|
||||
async handleLLMEnd(output: LLMResult): Promise<void> {
|
||||
// Token usage can be in different locations depending on the provider
|
||||
// Check llmOutput first (common for Anthropic and OpenAI)
|
||||
if (isRecord(output.llmOutput) && isRecord(output.llmOutput.usage)) {
|
||||
this.totalInputTokens += extractInputTokens(output.llmOutput.usage);
|
||||
this.totalOutputTokens += extractOutputTokens(output.llmOutput.usage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check generations for token usage in response_metadata
|
||||
for (const generation of output.generations.flat()) {
|
||||
if (isRecord(generation.generationInfo) && isRecord(generation.generationInfo.usage)) {
|
||||
this.totalInputTokens += extractInputTokens(generation.generationInfo.usage);
|
||||
this.totalOutputTokens += extractOutputTokens(generation.generationInfo.usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total accumulated token usage.
|
||||
*/
|
||||
getUsage(): AccumulatedTokenUsage {
|
||||
return {
|
||||
inputTokens: this.totalInputTokens,
|
||||
outputTokens: this.totalOutputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the accumulated usage (useful for reuse across multiple runs).
|
||||
*/
|
||||
reset(): void {
|
||||
this.totalInputTokens = 0;
|
||||
this.totalOutputTokens = 0;
|
||||
}
|
||||
}
|
||||
export {
|
||||
TokenUsageTrackingHandler,
|
||||
type AccumulatedTokenUsage,
|
||||
} from '../../src/utils/token-usage-tracking-handler';
|
||||
|
||||
@@ -15,18 +15,20 @@
|
||||
export { runEvaluation } from './harness/runner';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Feedback,
|
||||
EvaluationContext,
|
||||
TestCaseContext,
|
||||
GlobalRunContext,
|
||||
Evaluator,
|
||||
TestCase,
|
||||
RunConfig,
|
||||
ExampleResult,
|
||||
RunSummary,
|
||||
EvaluationLifecycle,
|
||||
LangsmithOptions,
|
||||
export {
|
||||
isGenerationResult,
|
||||
type Feedback,
|
||||
type EvaluationContext,
|
||||
type TestCaseContext,
|
||||
type GlobalRunContext,
|
||||
type Evaluator,
|
||||
type TestCase,
|
||||
type RunConfig,
|
||||
type ExampleResult,
|
||||
type RunSummary,
|
||||
type EvaluationLifecycle,
|
||||
type LangsmithOptions,
|
||||
type GenerationResult,
|
||||
} from './harness/harness-types';
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { KVMap } from 'langsmith/schemas';
|
||||
|
||||
import { isSimpleWorkflow } from './types';
|
||||
import type { EvalLogger } from '../harness/logger.js';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Type guards
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Graph validation evaluator - validates the internal graph structure
|
||||
* of generated TypeScript SDK code using workflow-sdk's validate() method.
|
||||
*/
|
||||
import { parseWorkflowCodeToBuilder } from '@n8n/workflow-sdk';
|
||||
|
||||
import { stripImportStatements } from '@/code-builder/utils/extract-code';
|
||||
import type {
|
||||
ProgrammaticViolation,
|
||||
ProgrammaticViolationName,
|
||||
SingleEvaluatorResult,
|
||||
} from '@/validation/types';
|
||||
|
||||
/**
|
||||
* Convert SDK validation code (SCREAMING_SNAKE_CASE) to violation name (graph-kebab-case).
|
||||
* E.g., "NO_NODES" -> "graph-no-nodes", "DISCONNECTED_NODE" -> "graph-disconnected-node"
|
||||
*/
|
||||
function codeToViolationName(code: string): ProgrammaticViolationName {
|
||||
const kebab = code.toLowerCase().replace(/_/g, '-');
|
||||
return `graph-${kebab}` as ProgrammaticViolationName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get violation type from a validation issue.
|
||||
* Uses the violationLevel from the issue if present, otherwise defaults to 'minor'.
|
||||
*/
|
||||
function getViolationType(issue: {
|
||||
violationLevel?: 'critical' | 'major' | 'minor';
|
||||
}): 'critical' | 'major' | 'minor' {
|
||||
return issue.violationLevel ?? 'minor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get points deducted for a violation based on its type
|
||||
*/
|
||||
function getPointsDeducted(type: 'critical' | 'major' | 'minor'): number {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return 25;
|
||||
case 'major':
|
||||
return 15;
|
||||
case 'minor':
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate generated TypeScript SDK code using graph validation.
|
||||
* Returns violations for any graph structure issues found.
|
||||
*
|
||||
* @param generatedCode - The TypeScript SDK code to validate
|
||||
* @returns Evaluation result with score and violations
|
||||
*/
|
||||
export function evaluateGraphValidation(generatedCode: string | undefined): SingleEvaluatorResult {
|
||||
// If no code provided, skip evaluation
|
||||
if (!generatedCode) {
|
||||
return {
|
||||
score: 1, // Neutral - not applicable
|
||||
violations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const violations: ProgrammaticViolation[] = [];
|
||||
|
||||
try {
|
||||
// Strip import statements before parsing (Acorn uses sourceType: 'script')
|
||||
const cleanCode = stripImportStatements(generatedCode);
|
||||
|
||||
// Parse code to WorkflowBuilder
|
||||
const builder = parseWorkflowCodeToBuilder(cleanCode);
|
||||
|
||||
// Run graph validation
|
||||
const validation = builder.validate();
|
||||
|
||||
// Convert errors to violations
|
||||
for (const error of validation.errors) {
|
||||
const violationType = getViolationType(error);
|
||||
violations.push({
|
||||
name: codeToViolationName(error.code),
|
||||
type: violationType,
|
||||
description: error.message,
|
||||
pointsDeducted: getPointsDeducted(violationType),
|
||||
});
|
||||
}
|
||||
|
||||
// Convert warnings to violations
|
||||
for (const warning of validation.warnings) {
|
||||
const violationType = getViolationType(warning);
|
||||
violations.push({
|
||||
name: codeToViolationName(warning.code),
|
||||
type: violationType,
|
||||
description: warning.message,
|
||||
pointsDeducted: getPointsDeducted(violationType),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Code parsing failed - this is a critical violation
|
||||
violations.push({
|
||||
name: 'graph-parse-error',
|
||||
type: 'critical',
|
||||
description: `Failed to parse code for graph validation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
pointsDeducted: 25,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate score (start at 100, deduct points)
|
||||
const totalDeducted = violations.reduce((sum, v) => sum + v.pointsDeducted, 0);
|
||||
const score = Math.max(0, 100 - totalDeducted) / 100;
|
||||
|
||||
return {
|
||||
score,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './agent-prompt';
|
||||
export * from './connections';
|
||||
export * from './credentials';
|
||||
export * from './from-ai';
|
||||
export * from './graph-validation';
|
||||
export * from './nodes';
|
||||
export * from './parameters';
|
||||
export * from './tools';
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ import type { ProgrammaticEvaluationInput, ProgrammaticViolation } from '@/valid
|
||||
import {
|
||||
evaluateConnections,
|
||||
evaluateCredentials,
|
||||
evaluateGraphValidation,
|
||||
evaluateNodes,
|
||||
evaluateParameters,
|
||||
evaluateTools,
|
||||
@@ -23,7 +24,7 @@ export async function programmaticEvaluation(
|
||||
input: ProgrammaticEvaluationInput,
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
) {
|
||||
const { generatedWorkflow, referenceWorkflows, preset = 'standard' } = input;
|
||||
const { generatedWorkflow, referenceWorkflows, generatedCode, preset = 'standard' } = input;
|
||||
|
||||
const connectionsEvaluationResult = evaluateConnections(generatedWorkflow, nodeTypes);
|
||||
const nodesEvaluationResult = evaluateNodes(generatedWorkflow, nodeTypes);
|
||||
@@ -32,6 +33,7 @@ export async function programmaticEvaluation(
|
||||
const toolsEvaluationResult = evaluateTools(generatedWorkflow, nodeTypes);
|
||||
const fromAiEvaluationResult = evaluateFromAi(generatedWorkflow, nodeTypes);
|
||||
const credentialsEvaluationResult = evaluateCredentials(generatedWorkflow);
|
||||
const graphValidationResult = evaluateGraphValidation(generatedCode);
|
||||
const nodeUsageEvaluationResult = evaluateNodeUsage(generatedWorkflow);
|
||||
const parametersEvaluationResult = evaluateParameters(generatedWorkflow, nodeTypes);
|
||||
|
||||
@@ -79,6 +81,7 @@ export async function programmaticEvaluation(
|
||||
nodeUsage: nodeUsageEvaluationResult,
|
||||
parameters: parametersEvaluationResult,
|
||||
similarity: similarityEvaluationResult,
|
||||
graphValidation: graphValidationResult,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -93,5 +96,6 @@ export async function programmaticEvaluation(
|
||||
nodeUsage: nodeUsageEvaluationResult,
|
||||
parameters: parametersEvaluationResult,
|
||||
similarity: similarityEvaluationResult,
|
||||
graphValidation: graphValidationResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type ImportanceTier = keyof typeof IMPORTANCE_TIER;
|
||||
|
||||
/**
|
||||
* Category importance assignments.
|
||||
* - high: Critical for workflow correctness (connections, trigger, parameters)
|
||||
* - high: Critical for workflow correctness (connections, trigger, parameters, graphValidation)
|
||||
* - medium: Important for quality (agentPrompt, tools, similarity)
|
||||
* - low: Nice to have (fromAi, nodeUsage)
|
||||
*/
|
||||
@@ -22,6 +22,7 @@ const CATEGORY_IMPORTANCE: Record<string, ImportanceTier> = {
|
||||
connections: 'high',
|
||||
trigger: 'high',
|
||||
parameters: 'high',
|
||||
graphValidation: 'high',
|
||||
agentPrompt: 'medium',
|
||||
tools: 'medium',
|
||||
similarity: 'medium',
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// ============================================================================
|
||||
// Agent Types
|
||||
// ============================================================================
|
||||
|
||||
export const AGENT_TYPES = {
|
||||
CODE_BUILDER: 'code-builder',
|
||||
MULTI_AGENT: 'multi-agent',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Evaluation Type Identifiers
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
import fs from 'fs';
|
||||
import { Client } from 'langsmith/client';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import path from 'path';
|
||||
|
||||
import { DEFAULT_MODEL, getApiKeyEnvVar, MODEL_FACTORIES, type ModelId } from '@/llm-config';
|
||||
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
import { WorkflowBuilderAgent } from '@/workflow-builder-agent';
|
||||
|
||||
import { loadNodesFromFile } from './load-nodes.js';
|
||||
import type { EvalLogger } from '../harness/logger.js';
|
||||
import { loadNodesFromFile } from './load-nodes';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import {
|
||||
createTraceFilters,
|
||||
isMinimalTracingEnabled,
|
||||
type TraceFilters,
|
||||
} from '../langsmith/trace-filters.js';
|
||||
} from '../langsmith/trace-filters';
|
||||
|
||||
/** Maximum memory for trace queue (3GB) */
|
||||
const MAX_INGEST_MEMORY_BYTES = 3 * 1024 * 1024 * 1024;
|
||||
@@ -69,6 +71,8 @@ export interface TestEnvironment {
|
||||
lsClient?: Client;
|
||||
/** Trace filtering utilities (only present when minimal tracing is enabled) */
|
||||
traceFilters?: TraceFilters;
|
||||
/** Directories containing generated node definition files */
|
||||
nodeDefinitionDirs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +173,51 @@ export function createLangsmithClient(logger?: EvalLogger): LangsmithClientResul
|
||||
return { client, traceFilters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve built-in node definition directories from installed node packages.
|
||||
* Mirrors `NodeDefinitionGeneratorService.getBuiltinDefinitionDirs()` for use
|
||||
* in the eval harness where the DI container is not available.
|
||||
*/
|
||||
export function resolveBuiltinNodeDefinitionDirs(): string[] {
|
||||
// In a pnpm monorepo, n8n-nodes-base and n8n-nodes-langchain are not direct
|
||||
// dependencies of ai-workflow-builder.ee, so bare require.resolve() fails.
|
||||
// Resolve from packages/cli which has them as dependencies.
|
||||
const repoRoot = findRepoRoot(__dirname);
|
||||
const resolvePaths = repoRoot ? [path.join(repoRoot, 'packages', 'cli')] : undefined;
|
||||
|
||||
const dirs: string[] = [];
|
||||
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
|
||||
try {
|
||||
const packageJsonPath = require.resolve(`${packageId}/package.json`, {
|
||||
paths: resolvePaths,
|
||||
});
|
||||
const distDir = path.dirname(packageJsonPath);
|
||||
const nodeDefsDir = path.join(distDir, 'dist', 'node-definitions');
|
||||
if (fs.existsSync(nodeDefsDir)) {
|
||||
dirs.push(nodeDefsDir);
|
||||
}
|
||||
} catch {
|
||||
// Package not installed, skip
|
||||
}
|
||||
}
|
||||
if (dirs.length === 0) {
|
||||
console.error('[NODE-DEFS] No node definition dirs resolved — get_node_types will fail');
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Walk up from startDir to find the monorepo root (contains pnpm-workspace.yaml). */
|
||||
function findRepoRoot(startDir: string): string | undefined {
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the test environment with LLM, nodes, and tracing
|
||||
* @param stageModels - Per-stage model configuration (optional, uses default model if not provided)
|
||||
@@ -197,6 +246,7 @@ export async function setupTestEnvironment(
|
||||
tracer,
|
||||
lsClient,
|
||||
traceFilters,
|
||||
nodeDefinitionDirs: resolveBuiltinNodeDefinitionDirs(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
// We need to mock the fs module before importing the module under test
|
||||
jest.mock('fs', () => ({
|
||||
readFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedReadFileSync = readFileSync as jest.MockedFunction<typeof readFileSync>;
|
||||
const mockedExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;
|
||||
|
||||
// Import after mocking
|
||||
import { loadNodesFromFile } from './load-nodes';
|
||||
|
||||
describe('loadNodesFromFile', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Default: legacy path exists
|
||||
mockedExistsSync.mockImplementation((path) => {
|
||||
return String(path).endsWith('nodes.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version handling', () => {
|
||||
it('should keep all version entries for a node type so older versions can be validated', () => {
|
||||
// This is the real-world scenario: removeDuplicates has two entries
|
||||
// - Entry 1: version [1, 1.1], defaultVersion 2
|
||||
// - Entry 2: version [2], defaultVersion 2
|
||||
// A workflow using typeVersion 1.1 should still be able to validate
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
version: [1, 1.1],
|
||||
defaultVersion: 2,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
version: [2],
|
||||
defaultVersion: 2,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
// The result should contain entries that cover ALL versions (1, 1.1, and 2)
|
||||
// so that workflows using any of these versions can be validated
|
||||
const removeDuplicatesEntries = result.filter(
|
||||
(n) => n.name === 'n8n-nodes-base.removeDuplicates',
|
||||
);
|
||||
|
||||
// We need at least the versions to be available for lookup
|
||||
// Either keep both entries, or merge the versions
|
||||
const allVersions = removeDuplicatesEntries.flatMap((entry) =>
|
||||
Array.isArray(entry.version) ? entry.version : [entry.version],
|
||||
);
|
||||
|
||||
expect(allVersions).toContain(1);
|
||||
expect(allVersions).toContain(1.1);
|
||||
expect(allVersions).toContain(2);
|
||||
});
|
||||
|
||||
it('should handle single-version nodes correctly', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
const codeNode = result.find((n) => n.name === 'n8n-nodes-base.code');
|
||||
|
||||
expect(codeNode).toBeDefined();
|
||||
expect(codeNode?.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle nodes with version array but no defaultVersion', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
version: [1, 2, 3],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
const httpNode = result.find((n) => n.name === 'n8n-nodes-base.httpRequest');
|
||||
|
||||
expect(httpNode).toBeDefined();
|
||||
// All versions should be available
|
||||
const versions = Array.isArray(httpNode?.version) ? httpNode.version : [httpNode?.version];
|
||||
expect(versions).toContain(1);
|
||||
expect(versions).toContain(2);
|
||||
expect(versions).toContain(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
it('should filter out ignored node types', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.toolVectorStore',
|
||||
displayName: 'Vector Store Tool',
|
||||
version: 1,
|
||||
inputs: [],
|
||||
outputs: ['ai_tool'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
expect(
|
||||
result.find((n) => n.name === '@n8n/n8n-nodes-langchain.toolVectorStore'),
|
||||
).toBeUndefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.code')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter out hidden nodes except dataTable', () => {
|
||||
const nodesData: Array<Partial<INodeTypeDescription>> = [
|
||||
{
|
||||
name: 'n8n-nodes-base.hiddenNode',
|
||||
displayName: 'Hidden Node',
|
||||
version: 1,
|
||||
hidden: true,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.dataTable',
|
||||
displayName: 'Data Table',
|
||||
version: 1,
|
||||
hidden: true,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
},
|
||||
];
|
||||
|
||||
mockedReadFileSync.mockReturnValue(JSON.stringify(nodesData));
|
||||
|
||||
const result = loadNodesFromFile();
|
||||
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.hiddenNode')).toBeUndefined();
|
||||
expect(result.find((n) => n.name === 'n8n-nodes-base.dataTable')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,32 +72,9 @@ export function loadNodesFromFile(): INodeTypeDescription[] {
|
||||
const nodesData = readFileSync(nodesPath, 'utf-8');
|
||||
const allNodes = jsonParse<NodeWithVersion[]>(nodesData);
|
||||
|
||||
// Group nodes by name and select latest version
|
||||
const nodesByName = new Map<string, NodeWithVersion[]>();
|
||||
for (const node of allNodes) {
|
||||
const existing = nodesByName.get(node.name) ?? [];
|
||||
existing.push(node);
|
||||
nodesByName.set(node.name, existing);
|
||||
}
|
||||
|
||||
const latestNodes: INodeTypeDescription[] = [];
|
||||
for (const [, versions] of nodesByName.entries()) {
|
||||
if (versions.length > 1) {
|
||||
// Find the node that matches the default version
|
||||
const selectedNode = versions.find(
|
||||
(node) =>
|
||||
node.defaultVersion !== undefined &&
|
||||
(Array.isArray(node.version)
|
||||
? node.version.includes(node.defaultVersion)
|
||||
: node.version === node.defaultVersion),
|
||||
);
|
||||
latestNodes.push(selectedNode ?? versions[0]);
|
||||
} else {
|
||||
latestNodes.push(versions[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filtering (ignored types, disabled nodes, hidden nodes, tool merging)
|
||||
// Keep all version entries instead of selecting only the latest version.
|
||||
// Workflows may use older node versions (e.g., removeDuplicates v1.1), and
|
||||
// discarding those entries causes "Node type not found" validation errors.
|
||||
const disabledNodes = getDisabledNodes();
|
||||
return filterNodeTypes(latestNodes, disabledNodes);
|
||||
return filterNodeTypes(allNodes, disabledNodes);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"build:with-cli": "pnpm build && pnpm --filter=n8n build",
|
||||
"format": "biome format --write src",
|
||||
"format:check": "biome ci src",
|
||||
"export:nodes": "../../../packages/cli/bin/n8n export:nodes --output ./evaluations/nodes.json",
|
||||
"test": "jest",
|
||||
"test:unit": "jest --config=jest.config.unit.js",
|
||||
"test:integration": "jest --config=jest.config.integration.js",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@n8n/config": "workspace:*",
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@n8n/workflow-sdk": "workspace:*",
|
||||
"@n8n_io/ai-assistant-sdk": "catalog:",
|
||||
"csv-parse": "5.5.0",
|
||||
"langsmith": "^0.4.2",
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { IUser, INodeTypeDescription, ITelemetryTrackProperties } from 'n8n
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { anthropicClaudeSonnet45 } from '@/llm-config';
|
||||
import { anthropicClaudeSonnet45, anthropicClaudeSonnet45Think } from '@/llm-config';
|
||||
import { SessionManagerService } from '@/session-manager.service';
|
||||
import { ResourceLocatorCallbackFactory } from '@/types/callbacks';
|
||||
import type { HITLInterruptValue } from '@/types/planning';
|
||||
@@ -40,6 +40,7 @@ export class AiWorkflowBuilderService {
|
||||
private readonly n8nVersion?: string,
|
||||
private readonly onCreditsUpdated?: OnCreditsUpdated,
|
||||
private readonly onTelemetryEvent?: OnTelemetryEvent,
|
||||
private readonly nodeDefinitionDirs?: string[],
|
||||
private readonly resourceLocatorCallbackFactory?: ResourceLocatorCallbackFactory,
|
||||
) {
|
||||
this.nodeTypes = this.filterNodeTypes(parsedNodeTypes);
|
||||
@@ -76,6 +77,26 @@ export class AiWorkflowBuilderService {
|
||||
});
|
||||
}
|
||||
|
||||
private static async getAnthropicClaudeThinkModel({
|
||||
baseUrl,
|
||||
authHeaders = {},
|
||||
apiKey = '-',
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
authHeaders?: Record<string, string>;
|
||||
apiKey?: string;
|
||||
} = {}): Promise<ChatAnthropic> {
|
||||
return await anthropicClaudeSonnet45Think({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
headers: {
|
||||
...authHeaders,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getApiProxyAuthHeaders(user: IUser, userMessageId: string) {
|
||||
assert(this.client);
|
||||
|
||||
@@ -93,6 +114,7 @@ export class AiWorkflowBuilderService {
|
||||
userMessageId: string,
|
||||
): Promise<{
|
||||
anthropicClaude: ChatAnthropic;
|
||||
anthropicClaudeThink: ChatAnthropic;
|
||||
tracingClient?: TracingClient;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
authHeaders?: { Authorization: string };
|
||||
@@ -105,10 +127,14 @@ export class AiWorkflowBuilderService {
|
||||
// Extract baseUrl from client configuration
|
||||
const baseUrl = this.client.getApiProxyBaseUrl();
|
||||
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
|
||||
const modelConfig = {
|
||||
baseUrl: baseUrl + '/anthropic',
|
||||
authHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel(modelConfig);
|
||||
const anthropicClaudeThink =
|
||||
await AiWorkflowBuilderService.getAnthropicClaudeThinkModel(modelConfig);
|
||||
|
||||
const tracingClient = new TracingClient({
|
||||
apiKey: '-',
|
||||
@@ -122,15 +148,16 @@ export class AiWorkflowBuilderService {
|
||||
},
|
||||
});
|
||||
|
||||
return { tracingClient, anthropicClaude, authHeaders };
|
||||
return { tracingClient, anthropicClaude, anthropicClaudeThink, authHeaders };
|
||||
}
|
||||
|
||||
// If base URL is not set, use environment variables
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel({
|
||||
apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '',
|
||||
});
|
||||
const envConfig = { apiKey: process.env.N8N_AI_ANTHROPIC_KEY ?? '' };
|
||||
const anthropicClaude = await AiWorkflowBuilderService.getAnthropicClaudeModel(envConfig);
|
||||
const anthropicClaudeThink =
|
||||
await AiWorkflowBuilderService.getAnthropicClaudeThinkModel(envConfig);
|
||||
|
||||
return { anthropicClaude };
|
||||
return { anthropicClaude, anthropicClaudeThink };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? `: ${error.message}` : '';
|
||||
const llmError = new LLMServiceError(`Failed to connect to LLM Provider${errorMessage}`, {
|
||||
@@ -177,10 +204,8 @@ export class AiWorkflowBuilderService {
|
||||
}
|
||||
|
||||
private async getAgent(user: IUser, userMessageId: string, featureFlags?: BuilderFeatureFlags) {
|
||||
const { anthropicClaude, tracingClient, authHeaders } = await this.setupModels(
|
||||
user,
|
||||
userMessageId,
|
||||
);
|
||||
const { anthropicClaude, anthropicClaudeThink, tracingClient, authHeaders } =
|
||||
await this.setupModels(user, userMessageId);
|
||||
|
||||
// Create resource locator callback scoped to this user if factory is provided
|
||||
const resourceLocatorCallback = this.resourceLocatorCallbackFactory?.(user.id);
|
||||
@@ -188,18 +213,24 @@ export class AiWorkflowBuilderService {
|
||||
const agent = new WorkflowBuilderAgent({
|
||||
parsedNodeTypes: this.nodeTypes,
|
||||
// Use the same model for all stages in production
|
||||
// When code builder is enabled, use thinking model for the builder stage
|
||||
stageLLMs: {
|
||||
supervisor: anthropicClaude,
|
||||
responder: anthropicClaude,
|
||||
discovery: anthropicClaude,
|
||||
builder: anthropicClaude,
|
||||
builder: featureFlags?.codeBuilder ? anthropicClaudeThink : anthropicClaude,
|
||||
parameterUpdater: anthropicClaude,
|
||||
planner: anthropicClaude,
|
||||
},
|
||||
logger: this.logger,
|
||||
checkpointer: this.sessionManager.getCheckpointer(),
|
||||
tracer: tracingClient
|
||||
? new LangChainTracer({ client: tracingClient, projectName: 'n8n-workflow-builder' })
|
||||
? new LangChainTracer({
|
||||
client: tracingClient,
|
||||
projectName: featureFlags?.codeBuilder
|
||||
? 'code-workflow-builder'
|
||||
: 'n8n-workflow-builder',
|
||||
})
|
||||
: undefined,
|
||||
instanceUrl: this.instanceUrl,
|
||||
runMetadata: {
|
||||
@@ -207,7 +238,9 @@ export class AiWorkflowBuilderService {
|
||||
featureFlags: featureFlags ?? {},
|
||||
},
|
||||
onGenerationSuccess: async () => await this.onGenerationSuccess(user, authHeaders),
|
||||
nodeDefinitionDirs: this.nodeDefinitionDirs,
|
||||
resourceLocatorCallback,
|
||||
onTelemetryEvent: this.onTelemetryEvent,
|
||||
});
|
||||
|
||||
return { agent };
|
||||
@@ -240,6 +273,7 @@ export class AiWorkflowBuilderService {
|
||||
const { agent } = await this.getAgent(user, payload.id, payload.featureFlags);
|
||||
const userId = user?.id?.toString();
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
const isCodeBuilder = payload.featureFlags?.codeBuilder ?? false;
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
|
||||
@@ -250,26 +284,7 @@ export class AiWorkflowBuilderService {
|
||||
// Store HITL interactions for session replay.
|
||||
// Command.update messages don't persist when a subgraph node interrupts multiple times.
|
||||
if (pendingHitl && payload.resumeData) {
|
||||
if (pendingHitl.value.type === 'questions') {
|
||||
this.sessionManager.addHitlEntry(threadId, {
|
||||
type: 'questions_answered',
|
||||
afterMessageId: pendingHitl.triggeringMessageId,
|
||||
interrupt: pendingHitl.value,
|
||||
answers: payload.resumeData,
|
||||
});
|
||||
} else if (pendingHitl.value.type === 'plan') {
|
||||
const decision = payload.resumeData as { action?: string; feedback?: string };
|
||||
// Only store non-approve decisions; approved plans survive in the checkpoint
|
||||
if (decision.action === 'reject' || decision.action === 'modify') {
|
||||
this.sessionManager.addHitlEntry(threadId, {
|
||||
type: 'plan_decided',
|
||||
afterMessageId: pendingHitl.triggeringMessageId,
|
||||
plan: pendingHitl.value.plan,
|
||||
decision: decision.action,
|
||||
feedback: decision.feedback,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.storeHitlInteraction(threadId, pendingHitl, payload.resumeData);
|
||||
}
|
||||
|
||||
const resumeInterrupt = pendingHitl?.value;
|
||||
@@ -287,67 +302,98 @@ export class AiWorkflowBuilderService {
|
||||
// Track telemetry after stream completes (onGenerationSuccess is called by the agent)
|
||||
if (this.onTelemetryEvent && userId) {
|
||||
try {
|
||||
await this.trackBuilderReplyTelemetry(agent, workflowId, userId, payload.id);
|
||||
await this.trackBuilderReplyTelemetry(agent, workflowId, userId, payload.id, isCodeBuilder);
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to track builder reply telemetry', { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private storeHitlInteraction(
|
||||
threadId: string,
|
||||
pendingHitl: { value: HITLInterruptValue; triggeringMessageId?: string },
|
||||
resumeData: unknown,
|
||||
): void {
|
||||
if (pendingHitl.value.type === 'questions') {
|
||||
this.sessionManager.addHitlEntry(threadId, {
|
||||
type: 'questions_answered',
|
||||
afterMessageId: pendingHitl.triggeringMessageId,
|
||||
interrupt: pendingHitl.value,
|
||||
answers: resumeData,
|
||||
});
|
||||
} else if (pendingHitl.value.type === 'plan') {
|
||||
const decision = resumeData as { action?: string; feedback?: string };
|
||||
// Only store non-approve decisions; approved plans survive in the checkpoint
|
||||
if (decision.action === 'reject' || decision.action === 'modify') {
|
||||
this.sessionManager.addHitlEntry(threadId, {
|
||||
type: 'plan_decided',
|
||||
afterMessageId: pendingHitl.triggeringMessageId,
|
||||
plan: pendingHitl.value.plan,
|
||||
decision: decision.action,
|
||||
feedback: decision.feedback,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractLastAiMessageContent(messages: BaseMessage[]): string {
|
||||
const lastAiMessage = messages.findLast(
|
||||
(m: BaseMessage): m is AIMessage => m instanceof AIMessage,
|
||||
);
|
||||
return typeof lastAiMessage?.content === 'string'
|
||||
? lastAiMessage.content
|
||||
: JSON.stringify(lastAiMessage?.content ?? '');
|
||||
}
|
||||
|
||||
private extractUniqueToolNames(messages: BaseMessage[]): string[] {
|
||||
const toolMessages = messages.filter(
|
||||
(m: BaseMessage): m is ToolMessage => m instanceof ToolMessage,
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
toolMessages
|
||||
.map((m: ToolMessage) => m.name)
|
||||
.filter((name: string | undefined): name is string => name !== undefined),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private async trackBuilderReplyTelemetry(
|
||||
agent: WorkflowBuilderAgent,
|
||||
workflowId: string | undefined,
|
||||
userId: string,
|
||||
userMessageId: string,
|
||||
isCodeBuilder: boolean,
|
||||
): Promise<void> {
|
||||
if (!this.onTelemetryEvent) return;
|
||||
|
||||
const state = await agent.getState(workflowId, userId);
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
const messages = state?.values?.messages ?? [];
|
||||
|
||||
// extract the last message that was sent to the user for telemetry
|
||||
const lastAiMessage = state.values.messages.findLast(
|
||||
(m: BaseMessage): m is AIMessage => m instanceof AIMessage,
|
||||
);
|
||||
const messageAi =
|
||||
typeof lastAiMessage?.content === 'string'
|
||||
? lastAiMessage.content
|
||||
: JSON.stringify(lastAiMessage?.content ?? '');
|
||||
|
||||
const toolMessages = state.values.messages.filter(
|
||||
(m: BaseMessage): m is ToolMessage => m instanceof ToolMessage,
|
||||
);
|
||||
const toolsCalled = [
|
||||
...new Set(
|
||||
toolMessages
|
||||
.map((m: ToolMessage) => m.name)
|
||||
.filter((name: string | undefined): name is string => name !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
// Build telemetry properties
|
||||
const properties: ITelemetryTrackProperties = {
|
||||
user_id: userId,
|
||||
instance_id: this.instanceId,
|
||||
workflow_id: workflowId,
|
||||
sequence_id: threadId,
|
||||
message_ai: messageAi,
|
||||
tools_called: toolsCalled,
|
||||
techniques_categories: state.values.techniqueCategories,
|
||||
validations: state.values.validationHistory,
|
||||
// Only include templates_selected when templates were actually used
|
||||
...(state.values.templateIds.length > 0 && {
|
||||
message_ai: this.extractLastAiMessageContent(messages),
|
||||
tools_called: this.extractUniqueToolNames(messages),
|
||||
techniques_categories: state?.values?.techniqueCategories,
|
||||
validations: state?.values?.validationHistory,
|
||||
...((state?.values?.templateIds?.length ?? 0) > 0 && {
|
||||
templates_selected: state.values.templateIds,
|
||||
}),
|
||||
user_message_id: userMessageId,
|
||||
code_builder: isCodeBuilder,
|
||||
};
|
||||
|
||||
this.onTelemetryEvent('Builder replied to user message', properties);
|
||||
}
|
||||
|
||||
async getSessions(workflowId: string | undefined, user?: IUser) {
|
||||
async getSessions(workflowId: string | undefined, user?: IUser, codeBuilder?: boolean) {
|
||||
const userId = user?.id?.toString();
|
||||
return await this.sessionManager.getSessions(workflowId, userId);
|
||||
const agentType = codeBuilder ? 'code-builder' : undefined;
|
||||
return await this.sessionManager.getSessions(workflowId, userId, agentType);
|
||||
}
|
||||
|
||||
async getBuilderInstanceCredits(
|
||||
@@ -372,8 +418,15 @@ export class AiWorkflowBuilderService {
|
||||
workflowId: string,
|
||||
user: IUser,
|
||||
messageId: string,
|
||||
codeBuilder?: boolean,
|
||||
): Promise<boolean> {
|
||||
return await this.sessionManager.truncateMessagesAfter(workflowId, user.id, messageId);
|
||||
const agentType = codeBuilder ? 'code-builder' : undefined;
|
||||
return await this.sessionManager.truncateMessagesAfter(
|
||||
workflowId,
|
||||
user.id,
|
||||
messageId,
|
||||
agentType,
|
||||
);
|
||||
}
|
||||
|
||||
private static readonly questionsInterruptSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
/**
|
||||
* Code Builder Agent
|
||||
*
|
||||
* Unified agent that generates complete workflows using TypeScript SDK format with an agentic loop
|
||||
* that handles tool calls for node discovery before producing the final workflow.
|
||||
*
|
||||
* This replaces the split Planning Agent + Coding Agent architecture by combining both
|
||||
* discovery and code generation in a single, context-preserving agent.
|
||||
*/
|
||||
|
||||
import type { CallbackManagerForChainRun } from '@langchain/core/callbacks/manager';
|
||||
import { CallbackManager } from '@langchain/core/callbacks/manager';
|
||||
import type { AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { setSchemaBaseDirs } from '@n8n/workflow-sdk';
|
||||
import type { ITelemetryTrackProperties } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
StreamOutput,
|
||||
AgentMessageChunk,
|
||||
WorkflowUpdateChunk,
|
||||
SessionMessagesChunk,
|
||||
} from '../types/streaming';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
import {
|
||||
CODE_BUILDER_GET_NODE_TYPES_TOOL,
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL,
|
||||
CODE_BUILDER_SEARCH_NODES_TOOL,
|
||||
CODE_BUILDER_THINK_TOOL,
|
||||
MAX_AGENT_ITERATIONS,
|
||||
MAX_VALIDATE_ATTEMPTS,
|
||||
} from './constants';
|
||||
import { AgentIterationHandler } from './handlers/agent-iteration-handler';
|
||||
import { AutoFinalizeHandler } from './handlers/auto-finalize-handler';
|
||||
import { ChatSetupHandler, type LlmWithTools } from './handlers/chat-setup-handler';
|
||||
import { FinalResponseHandler } from './handlers/final-response-handler';
|
||||
import { ParseValidateHandler } from './handlers/parse-validate-handler';
|
||||
import type { TextEditorHandler } from './handlers/text-editor-handler';
|
||||
import type { TextEditorToolHandler } from './handlers/text-editor-tool-handler';
|
||||
import { ToolDispatchHandler } from './handlers/tool-dispatch-handler';
|
||||
import { ValidateToolHandler } from './handlers/validate-tool-handler';
|
||||
import type { HistoryContext } from './prompts';
|
||||
import { WarningTracker } from './state/warning-tracker';
|
||||
import { createCodeBuilderGetTool } from './tools/code-builder-get.tool';
|
||||
import { createCodeBuilderSearchTool } from './tools/code-builder-search.tool';
|
||||
import { createGetSuggestedNodesTool } from './tools/get-suggested-nodes.tool';
|
||||
import { createThinkTool } from './tools/think.tool';
|
||||
import type { CodeBuilderAgentConfig, TokenUsage } from './types';
|
||||
export type { CodeBuilderAgentConfig } from './types';
|
||||
import { sanitizeLlmErrorMessage } from '../utils/error-sanitizer';
|
||||
import { pushValidationFeedback } from './utils/content-extractors';
|
||||
import { calculateNodeChanges } from './utils/node-diff';
|
||||
import { NodeTypeParser } from './utils/node-type-parser';
|
||||
|
||||
/**
|
||||
* Code Builder Agent
|
||||
*
|
||||
* Generates workflows by:
|
||||
* 1. Building a comprehensive system prompt with workflow patterns
|
||||
* 2. Running an agentic loop that handles tool calls for node discovery
|
||||
* 3. Parsing the final TypeScript code to WorkflowJSON
|
||||
* 4. Validating and streaming the result
|
||||
*/
|
||||
export class CodeBuilderAgent {
|
||||
private nodeTypeParser: NodeTypeParser;
|
||||
private logger?: Logger;
|
||||
private tools: StructuredToolInterface[];
|
||||
private toolsMap: Map<string, StructuredToolInterface>;
|
||||
private parseValidateHandler: ParseValidateHandler;
|
||||
private autoFinalizeHandler: AutoFinalizeHandler;
|
||||
private validateToolHandler: ValidateToolHandler;
|
||||
private iterationHandler: AgentIterationHandler;
|
||||
private finalResponseHandler: FinalResponseHandler;
|
||||
private chatSetupHandler: ChatSetupHandler;
|
||||
private toolDispatchHandler: ToolDispatchHandler;
|
||||
/** Optional callback for emitting telemetry events */
|
||||
private onTelemetryEvent?: (event: string, properties: ITelemetryTrackProperties) => void;
|
||||
/** Token usage accumulator - tracks original callback and accumulated totals */
|
||||
private originalOnTokenUsage?: (usage: TokenUsage) => void;
|
||||
/** Accumulated token usage for the current chat session */
|
||||
private accumulatedTokens: { inputTokens: number; outputTokens: number; thinkingTokens: number } =
|
||||
{
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
thinkingTokens: 0,
|
||||
};
|
||||
|
||||
constructor(config: CodeBuilderAgentConfig) {
|
||||
this.nodeTypeParser = new NodeTypeParser(config.nodeTypes);
|
||||
this.logger = config.logger;
|
||||
this.onTelemetryEvent = config.onTelemetryEvent;
|
||||
this.originalOnTokenUsage = config.onTokenUsage;
|
||||
|
||||
// Configure schema validator to search the same dirs as get_node_types
|
||||
if (config.nodeDefinitionDirs) {
|
||||
setSchemaBaseDirs(config.nodeDefinitionDirs);
|
||||
}
|
||||
|
||||
this.parseValidateHandler = new ParseValidateHandler({
|
||||
logger: config.logger,
|
||||
});
|
||||
|
||||
// Initialize auto-finalize handler
|
||||
this.autoFinalizeHandler = new AutoFinalizeHandler({
|
||||
parseAndValidate: async (code, currentWorkflow) =>
|
||||
await this.parseValidateHandler.parseAndValidate(code, currentWorkflow),
|
||||
getErrorContext: (code, errorMessage) =>
|
||||
this.parseValidateHandler.getErrorContext(code, errorMessage),
|
||||
});
|
||||
|
||||
// Initialize validate tool handler
|
||||
this.validateToolHandler = new ValidateToolHandler({
|
||||
parseAndValidate: async (code, currentWorkflow) =>
|
||||
await this.parseValidateHandler.parseAndValidate(code, currentWorkflow),
|
||||
getErrorContext: (code, errorMessage) =>
|
||||
this.parseValidateHandler.getErrorContext(code, errorMessage),
|
||||
});
|
||||
|
||||
// Initialize iteration handler with wrapped token callback that accumulates totals
|
||||
this.iterationHandler = new AgentIterationHandler({
|
||||
onTokenUsage: (usage) => {
|
||||
// Accumulate tokens for telemetry
|
||||
this.accumulatedTokens.inputTokens += usage.inputTokens;
|
||||
this.accumulatedTokens.outputTokens += usage.outputTokens;
|
||||
this.accumulatedTokens.thinkingTokens += usage.thinkingTokens;
|
||||
// Call original callback if provided
|
||||
this.originalOnTokenUsage?.(usage);
|
||||
},
|
||||
callbacks: config.callbacks,
|
||||
runMetadata: config.runMetadata,
|
||||
});
|
||||
|
||||
// Initialize final response handler
|
||||
this.finalResponseHandler = new FinalResponseHandler({
|
||||
parseAndValidate: async (code, currentWorkflow) =>
|
||||
await this.parseValidateHandler.parseAndValidate(code, currentWorkflow),
|
||||
});
|
||||
|
||||
// Create tools
|
||||
const searchTool = createCodeBuilderSearchTool(this.nodeTypeParser);
|
||||
const getTool = createCodeBuilderGetTool({ nodeDefinitionDirs: config.nodeDefinitionDirs });
|
||||
const suggestedNodesTool = createGetSuggestedNodesTool(this.nodeTypeParser);
|
||||
const thinkTool = createThinkTool();
|
||||
this.tools = [searchTool, getTool, suggestedNodesTool, thinkTool];
|
||||
this.toolsMap = new Map(this.tools.map((t) => [t.name, t]));
|
||||
|
||||
// Initialize chat setup handler
|
||||
this.chatSetupHandler = new ChatSetupHandler({
|
||||
llm: config.llm,
|
||||
tools: this.tools,
|
||||
enableTextEditorConfig: config.enableTextEditor,
|
||||
parseAndValidate: async (code, currentWorkflow) =>
|
||||
await this.parseValidateHandler.parseAndValidate(code, currentWorkflow),
|
||||
getErrorContext: (code, errorMessage) =>
|
||||
this.parseValidateHandler.getErrorContext(code, errorMessage),
|
||||
});
|
||||
|
||||
// Initialize tool dispatch handler
|
||||
this.toolDispatchHandler = new ToolDispatchHandler({
|
||||
toolsMap: this.toolsMap,
|
||||
toolDisplayTitles: new Map([
|
||||
[CODE_BUILDER_SEARCH_NODES_TOOL.toolName, CODE_BUILDER_SEARCH_NODES_TOOL.displayTitle],
|
||||
[CODE_BUILDER_GET_NODE_TYPES_TOOL.toolName, CODE_BUILDER_GET_NODE_TYPES_TOOL.displayTitle],
|
||||
[
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL.toolName,
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL.displayTitle,
|
||||
],
|
||||
[CODE_BUILDER_THINK_TOOL.toolName, CODE_BUILDER_THINK_TOOL.displayTitle],
|
||||
]),
|
||||
validateToolHandler: this.validateToolHandler,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset accumulated token counters for a new chat session
|
||||
*/
|
||||
private resetAccumulatedTokens(): void {
|
||||
this.accumulatedTokens = { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit "Code builder agent ran" telemetry event
|
||||
*/
|
||||
private emitTelemetryEvent(params: {
|
||||
userId: string;
|
||||
workflowId?: string;
|
||||
userMessageId: string;
|
||||
sessionId?: string;
|
||||
status: 'success' | 'error' | 'aborted';
|
||||
errorMessage?: string;
|
||||
iterationCount: number;
|
||||
durationMs: number;
|
||||
beforeWorkflow?: WorkflowJSON;
|
||||
afterWorkflow?: WorkflowJSON | null;
|
||||
}): void {
|
||||
if (!this.onTelemetryEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
userId,
|
||||
workflowId,
|
||||
userMessageId,
|
||||
sessionId,
|
||||
status,
|
||||
errorMessage,
|
||||
iterationCount,
|
||||
durationMs,
|
||||
beforeWorkflow,
|
||||
afterWorkflow,
|
||||
} = params;
|
||||
|
||||
const nodeChanges = calculateNodeChanges(beforeWorkflow, afterWorkflow);
|
||||
|
||||
const properties: ITelemetryTrackProperties = {
|
||||
user_id: userId,
|
||||
workflow_id: workflowId,
|
||||
user_message_id: userMessageId,
|
||||
session_id: sessionId,
|
||||
status,
|
||||
duration_ms: durationMs,
|
||||
iteration_count: iterationCount,
|
||||
input_tokens: this.accumulatedTokens.inputTokens,
|
||||
output_tokens: this.accumulatedTokens.outputTokens,
|
||||
thinking_tokens: this.accumulatedTokens.thinkingTokens,
|
||||
nodes_added: nodeChanges.nodes_added,
|
||||
nodes_removed: nodeChanges.nodes_removed,
|
||||
nodes_modified: nodeChanges.nodes_modified,
|
||||
};
|
||||
|
||||
if (errorMessage) {
|
||||
properties.error_message = errorMessage;
|
||||
}
|
||||
|
||||
this.onTelemetryEvent('Code builder agent ran', properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main chat method - generates workflow and streams output
|
||||
* Implements an agentic loop that handles tool calls for node discovery
|
||||
*
|
||||
* @param payload - Chat payload with message and workflow context
|
||||
* @param userId - User ID for logging
|
||||
* @param abortSignal - Optional abort signal
|
||||
* @param historyContext - Optional conversation history for multi-turn refinement
|
||||
*/
|
||||
async *chat(
|
||||
payload: ChatPayload,
|
||||
userId: string,
|
||||
abortSignal?: AbortSignal,
|
||||
historyContext?: HistoryContext,
|
||||
): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Reset accumulated tokens for this chat session
|
||||
this.resetAccumulatedTokens();
|
||||
|
||||
// Capture before workflow for node diff calculation
|
||||
const beforeWorkflow = payload.workflowContext?.currentWorkflow as WorkflowJSON | undefined;
|
||||
|
||||
const workflowId = beforeWorkflow?.id;
|
||||
|
||||
// Track state for telemetry in catch block
|
||||
let iteration = 0;
|
||||
|
||||
try {
|
||||
this.logger?.debug('Code builder agent starting', {
|
||||
userId,
|
||||
messageLength: payload.message.length,
|
||||
});
|
||||
|
||||
// Setup phase - build prompt, bind tools, format messages, initialize handlers
|
||||
const setupResult = await this.chatSetupHandler.execute({
|
||||
payload,
|
||||
historyContext,
|
||||
});
|
||||
|
||||
const {
|
||||
llmWithTools,
|
||||
messages,
|
||||
textEditorEnabled,
|
||||
currentWorkflow,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
} = setupResult;
|
||||
|
||||
// Run agentic loop
|
||||
const loopResult = yield* this.runAgenticLoop({
|
||||
llmWithTools,
|
||||
messages,
|
||||
textEditorEnabled,
|
||||
currentWorkflow,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
abortSignal,
|
||||
payload,
|
||||
previousMessages: historyContext?.userMessages ?? [],
|
||||
});
|
||||
|
||||
const { workflow } = loopResult;
|
||||
iteration = loopResult.iteration;
|
||||
|
||||
if (!workflow) {
|
||||
throw new Error(
|
||||
`Failed to generate workflow after ${MAX_AGENT_ITERATIONS} iterations. The agent may be stuck in a tool-calling loop.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Log success
|
||||
this.logger?.info('Code builder agent generated workflow', {
|
||||
userId,
|
||||
nodeCount: workflow.nodes.length,
|
||||
iterations: iteration,
|
||||
});
|
||||
|
||||
// Stream workflow update
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify(workflow, null, 2),
|
||||
iterationCount: iteration,
|
||||
} as WorkflowUpdateChunk,
|
||||
],
|
||||
};
|
||||
|
||||
// Yield session messages for persistence (includes tool calls and results)
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'session-messages',
|
||||
messages,
|
||||
} as SessionMessagesChunk,
|
||||
],
|
||||
};
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
// Emit success telemetry
|
||||
this.emitTelemetryEvent({
|
||||
userId,
|
||||
workflowId,
|
||||
userMessageId: payload.id,
|
||||
status: 'success',
|
||||
iterationCount: iteration,
|
||||
durationMs: totalDuration,
|
||||
beforeWorkflow,
|
||||
afterWorkflow: workflow,
|
||||
});
|
||||
} catch (error) {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const rawErrorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
const userFacingMessage = sanitizeLlmErrorMessage(error);
|
||||
|
||||
this.logger?.error('Code builder agent failed', {
|
||||
userId,
|
||||
error: rawErrorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
// Stream sanitized error message to user
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: `I encountered an error while generating the workflow. ${userFacingMessage}`,
|
||||
} as AgentMessageChunk,
|
||||
],
|
||||
};
|
||||
|
||||
// Determine if this was an abort or a regular error
|
||||
const isAborted =
|
||||
error instanceof Error && (error.name === 'AbortError' || error.message === 'Aborted');
|
||||
const status = isAborted ? 'aborted' : 'error';
|
||||
|
||||
// Emit error/abort telemetry
|
||||
this.emitTelemetryEvent({
|
||||
userId,
|
||||
workflowId,
|
||||
userMessageId: payload.id,
|
||||
status,
|
||||
errorMessage: isAborted ? undefined : rawErrorMessage,
|
||||
iterationCount: iteration,
|
||||
durationMs: totalDuration,
|
||||
beforeWorkflow,
|
||||
afterWorkflow: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the agentic loop that invokes LLM and processes tool calls
|
||||
*/
|
||||
private async *runAgenticLoop(
|
||||
params: AgenticLoopParams,
|
||||
): AsyncGenerator<StreamOutput, AgenticLoopResult, unknown> {
|
||||
const {
|
||||
llmWithTools,
|
||||
messages,
|
||||
textEditorEnabled,
|
||||
currentWorkflow,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
abortSignal,
|
||||
payload,
|
||||
previousMessages,
|
||||
} = params;
|
||||
|
||||
const state: AgenticLoopState = {
|
||||
iteration: 0,
|
||||
consecutiveParseErrors: 0,
|
||||
workflow: null,
|
||||
parseDuration: 0,
|
||||
sourceCode: null,
|
||||
textEditorValidateAttempts: 0,
|
||||
warningTracker: new WarningTracker(),
|
||||
outputTrace: [],
|
||||
hasUnvalidatedEdits: false,
|
||||
};
|
||||
|
||||
// Pre-validate existing workflow to discover pre-existing warnings
|
||||
if (currentWorkflow) {
|
||||
try {
|
||||
const preExisting = this.parseValidateHandler.validateExistingWorkflow(currentWorkflow);
|
||||
if (preExisting.length > 0) {
|
||||
state.warningTracker.markAsPreExisting(preExisting);
|
||||
}
|
||||
} catch {
|
||||
// Pre-validation failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// Create a parent run to group all LLM invocations under a single trace
|
||||
const callbackManager = CallbackManager.configure(
|
||||
this.iterationHandler.getCallbacks(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
this.iterationHandler.getRunMetadata(),
|
||||
);
|
||||
let parentRunManager: CallbackManagerForChainRun | undefined;
|
||||
if (callbackManager) {
|
||||
const isFirstGeneration = !payload.workflowContext?.currentWorkflow?.nodes?.length;
|
||||
const isFirstMessage = previousMessages.length === 0;
|
||||
|
||||
parentRunManager = await callbackManager.handleChainStart(
|
||||
{ lc: 1, type: 'not_implemented' as const, id: ['CodeBuilderAgent'] },
|
||||
{
|
||||
payload,
|
||||
previousMessages,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
...this.iterationHandler.getRunMetadata(),
|
||||
first_generation: isFirstGeneration,
|
||||
first_message: isFirstMessage,
|
||||
},
|
||||
'CodeBuilderAgentLoop',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
while (state.iteration < MAX_AGENT_ITERATIONS) {
|
||||
this.checkConsecutiveParseErrors(state.consecutiveParseErrors);
|
||||
state.iteration++;
|
||||
|
||||
// Derive child callbacks for this iteration so the LLM call is nested under the parent run
|
||||
const childCallbacks = parentRunManager?.getChild(`iteration_${state.iteration}`);
|
||||
|
||||
// Invoke LLM and stream response
|
||||
const llmResult = yield* this.iterationHandler.invokeLlm({
|
||||
llmWithTools,
|
||||
messages,
|
||||
abortSignal,
|
||||
iteration: state.iteration,
|
||||
callbacks: childCallbacks,
|
||||
});
|
||||
|
||||
// Accumulate output trace entries for the parent LangSmith trace
|
||||
if (llmResult.textContent) {
|
||||
state.outputTrace.push({ type: 'text', text: llmResult.textContent });
|
||||
}
|
||||
if (llmResult.hasToolCalls && llmResult.response.tool_calls) {
|
||||
for (const toolCall of llmResult.response.tool_calls) {
|
||||
state.outputTrace.push({ type: 'tool-call', toolName: toolCall.name });
|
||||
}
|
||||
}
|
||||
|
||||
const iterationResult = yield* this.processIteration({
|
||||
llmResult,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
textEditorEnabled,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
state,
|
||||
});
|
||||
|
||||
if (iterationResult.shouldBreak) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await parentRunManager?.handleChainEnd({
|
||||
iterations: state.iteration,
|
||||
hasWorkflow: !!state.workflow,
|
||||
outputTrace: state.outputTrace,
|
||||
output: state.workflow
|
||||
? { code: state.sourceCode, workflow: JSON.stringify(state.workflow) }
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
await parentRunManager?.handleChainError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: state.workflow,
|
||||
parseDuration: state.parseDuration,
|
||||
sourceCode: state.sourceCode,
|
||||
iteration: state.iteration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we've hit the consecutive parse error limit
|
||||
*/
|
||||
private checkConsecutiveParseErrors(count: number): void {
|
||||
if (count >= 3) {
|
||||
throw new Error('Failed to parse workflow code after 3 consecutive attempts.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single iteration of the agentic loop
|
||||
*/
|
||||
private async *processIteration(params: {
|
||||
llmResult: { hasToolCalls: boolean; response: AIMessage };
|
||||
messages: BaseMessage[];
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
textEditorEnabled: boolean;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
state: AgenticLoopState;
|
||||
}): AsyncGenerator<StreamOutput, { shouldBreak: boolean }, unknown> {
|
||||
const {
|
||||
llmResult,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
textEditorEnabled,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
state,
|
||||
} = params;
|
||||
|
||||
const response = llmResult.response;
|
||||
|
||||
// Branch 1: Process tool calls
|
||||
if (llmResult.hasToolCalls && response.tool_calls) {
|
||||
return yield* this.handleToolCalls({
|
||||
toolCalls: response.tool_calls,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
textEditorEnabled,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
// Branch 2: Text editor auto-finalize
|
||||
if (textEditorEnabled && textEditorHandler) {
|
||||
return yield* this.handleTextEditorAutoFinalize({
|
||||
textEditorHandler,
|
||||
currentWorkflow,
|
||||
messages,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
// Branch 3: Final response processing
|
||||
return await this.handleFinalResponse({
|
||||
response: llmResult.response,
|
||||
currentWorkflow,
|
||||
messages,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tool calls from LLM response
|
||||
*/
|
||||
private async *handleToolCalls(params: {
|
||||
toolCalls: Array<{ name: string; args: Record<string, unknown>; id?: string }>;
|
||||
messages: BaseMessage[];
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
textEditorEnabled: boolean;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
state: AgenticLoopState;
|
||||
}): AsyncGenerator<StreamOutput, { shouldBreak: boolean }, unknown> {
|
||||
const {
|
||||
toolCalls,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
textEditorEnabled,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
state,
|
||||
} = params;
|
||||
|
||||
state.consecutiveParseErrors = 0;
|
||||
|
||||
const dispatchResult = yield* this.toolDispatchHandler.dispatch({
|
||||
toolCalls,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
iteration: state.iteration,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
warningTracker: state.warningTracker,
|
||||
});
|
||||
|
||||
// Update state from dispatch result
|
||||
if (dispatchResult.hasUnvalidatedEdits !== undefined) {
|
||||
state.hasUnvalidatedEdits = dispatchResult.hasUnvalidatedEdits;
|
||||
}
|
||||
if (dispatchResult.workflow) {
|
||||
state.workflow = dispatchResult.workflow;
|
||||
}
|
||||
if (dispatchResult.parseDuration !== undefined) {
|
||||
state.parseDuration = dispatchResult.parseDuration;
|
||||
}
|
||||
if (dispatchResult.workflowReady) {
|
||||
state.sourceCode = dispatchResult.sourceCode ?? null;
|
||||
return { shouldBreak: true };
|
||||
}
|
||||
|
||||
// Check for too many validate failures
|
||||
if (textEditorEnabled && state.textEditorValidateAttempts >= MAX_VALIDATE_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`Failed to generate valid workflow after ${MAX_VALIDATE_ATTEMPTS} validate attempts.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { shouldBreak: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text editor auto-finalize when no tool calls
|
||||
*/
|
||||
private async *handleTextEditorAutoFinalize(params: {
|
||||
textEditorHandler: TextEditorHandler;
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
messages: BaseMessage[];
|
||||
state: AgenticLoopState;
|
||||
}): AsyncGenerator<StreamOutput, { shouldBreak: boolean }, unknown> {
|
||||
const { textEditorHandler, currentWorkflow, messages, state } = params;
|
||||
|
||||
const code = textEditorHandler.getWorkflowCode();
|
||||
|
||||
// Skip validation if code exists but no edits since last validation
|
||||
if (!state.hasUnvalidatedEdits && code) {
|
||||
if (state.workflow) {
|
||||
state.sourceCode = code;
|
||||
return { shouldBreak: true };
|
||||
}
|
||||
pushValidationFeedback(messages, 'Please use the text editor to fix the validation errors.');
|
||||
return { shouldBreak: false };
|
||||
}
|
||||
|
||||
state.textEditorValidateAttempts++;
|
||||
state.hasUnvalidatedEdits = false;
|
||||
|
||||
const autoFinalizeResult = yield* this.autoFinalizeHandler.execute({
|
||||
code,
|
||||
currentWorkflow,
|
||||
messages,
|
||||
warningTracker: state.warningTracker,
|
||||
});
|
||||
|
||||
if (autoFinalizeResult.success && autoFinalizeResult.workflow) {
|
||||
state.workflow = autoFinalizeResult.workflow;
|
||||
state.parseDuration = autoFinalizeResult.parseDuration ?? 0;
|
||||
state.sourceCode = textEditorHandler.getWorkflowCode() ?? null;
|
||||
return { shouldBreak: true };
|
||||
}
|
||||
|
||||
if (autoFinalizeResult.parseDuration) {
|
||||
state.parseDuration = autoFinalizeResult.parseDuration;
|
||||
}
|
||||
|
||||
return { shouldBreak: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle final response when no tool calls (non-text-editor mode)
|
||||
*/
|
||||
private async handleFinalResponse(params: {
|
||||
response: AIMessage;
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
messages: BaseMessage[];
|
||||
state: AgenticLoopState;
|
||||
}): Promise<{ shouldBreak: boolean }> {
|
||||
const { response, currentWorkflow, messages, state } = params;
|
||||
|
||||
const finalResult = await this.finalResponseHandler.process({
|
||||
response,
|
||||
currentWorkflow,
|
||||
messages,
|
||||
warningTracker: state.warningTracker,
|
||||
});
|
||||
|
||||
if (finalResult.parseDuration !== undefined) {
|
||||
state.parseDuration = finalResult.parseDuration;
|
||||
}
|
||||
if (finalResult.isParseError) {
|
||||
state.consecutiveParseErrors++;
|
||||
}
|
||||
|
||||
if (finalResult.success && finalResult.workflow) {
|
||||
state.workflow = finalResult.workflow;
|
||||
if (finalResult.sourceCode) {
|
||||
state.sourceCode = finalResult.sourceCode;
|
||||
}
|
||||
return { shouldBreak: true };
|
||||
}
|
||||
|
||||
return { shouldBreak: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the agentic loop
|
||||
*/
|
||||
interface AgenticLoopParams {
|
||||
llmWithTools: LlmWithTools;
|
||||
messages: BaseMessage[];
|
||||
textEditorEnabled: boolean;
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
abortSignal?: AbortSignal;
|
||||
payload: ChatPayload;
|
||||
previousMessages: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the agentic loop
|
||||
*/
|
||||
interface AgenticLoopResult {
|
||||
workflow: WorkflowJSON | null;
|
||||
parseDuration: number;
|
||||
sourceCode: string | null;
|
||||
iteration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry in the output trace — either user-facing text or a tool call name.
|
||||
* Args/results are available in child runs, so only the name is captured here.
|
||||
*/
|
||||
type TraceEntry = { type: 'text'; text: string } | { type: 'tool-call'; toolName: string };
|
||||
|
||||
/**
|
||||
* Mutable state for the agentic loop
|
||||
*/
|
||||
interface AgenticLoopState {
|
||||
iteration: number;
|
||||
consecutiveParseErrors: number;
|
||||
workflow: WorkflowJSON | null;
|
||||
parseDuration: number;
|
||||
sourceCode: string | null;
|
||||
textEditorValidateAttempts: number;
|
||||
warningTracker: WarningTracker;
|
||||
outputTrace: TraceEntry[];
|
||||
/** Whether the agent has made code edits that haven't been followed by validation */
|
||||
hasUnvalidatedEdits: boolean;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Code Workflow Builder
|
||||
*
|
||||
* Public entry point for the workflow generation system.
|
||||
*
|
||||
* Architecture:
|
||||
* Uses a unified CodeBuilderAgent that combines node discovery and code generation
|
||||
* in a single agentic loop, preserving full context throughout the process.
|
||||
*
|
||||
* Session Management:
|
||||
* Supports multi-turn conversations through session persistence. User messages are
|
||||
* stored and can be compacted when the count exceeds a threshold. This allows users
|
||||
* to make incremental refinement requests without re-explaining the whole workflow.
|
||||
*/
|
||||
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription, ITelemetryTrackProperties } from 'n8n-workflow';
|
||||
|
||||
import type { StreamOutput } from '../types/streaming';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
import { CodeBuilderAgent } from './code-builder-agent';
|
||||
import { SessionChatHandler } from './handlers/session-chat-handler';
|
||||
import type { TokenUsage } from './types';
|
||||
export type { TokenUsage };
|
||||
|
||||
/**
|
||||
* Configuration for CodeWorkflowBuilder
|
||||
*/
|
||||
export interface CodeWorkflowBuilderConfig {
|
||||
/** LLM for workflow generation */
|
||||
llm: BaseChatModel;
|
||||
/** Parsed node types from n8n */
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
/** Optional logger */
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
/**
|
||||
* Optional checkpointer for session persistence.
|
||||
* If provided, enables multi-turn conversation history.
|
||||
*/
|
||||
checkpointer?: MemorySaver;
|
||||
/**
|
||||
* Callback when generation completes successfully (not aborted).
|
||||
* Used for credit deduction and UI updates.
|
||||
*/
|
||||
onGenerationSuccess?: () => Promise<void>;
|
||||
/**
|
||||
* Optional callback to receive token usage data from each LLM invocation.
|
||||
* Called after each LLM call completes. Callers can accumulate for totals.
|
||||
*/
|
||||
onTokenUsage?: (usage: TokenUsage) => void;
|
||||
/**
|
||||
* Optional LangChain callbacks (e.g., LangSmith tracer) for LLM invocations.
|
||||
*/
|
||||
callbacks?: Callbacks;
|
||||
/**
|
||||
* Optional metadata to include in LangSmith traces.
|
||||
*/
|
||||
runMetadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Optional callback for emitting telemetry events.
|
||||
*/
|
||||
onTelemetryEvent?: (event: string, properties: ITelemetryTrackProperties) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code Workflow Builder
|
||||
*
|
||||
* Generates n8n workflows using a unified CodeBuilderAgent that handles
|
||||
* both node discovery and code generation in a single pass.
|
||||
*
|
||||
* Supports multi-turn conversations through session persistence when a
|
||||
* checkpointer is provided.
|
||||
*/
|
||||
export class CodeWorkflowBuilder {
|
||||
private codeBuilderAgent: CodeBuilderAgent;
|
||||
private logger?: Logger;
|
||||
private sessionChatHandler?: SessionChatHandler;
|
||||
private onGenerationSuccess?: () => Promise<void>;
|
||||
|
||||
constructor(config: CodeWorkflowBuilderConfig) {
|
||||
this.codeBuilderAgent = new CodeBuilderAgent({
|
||||
llm: config.llm,
|
||||
nodeTypes: config.nodeTypes,
|
||||
logger: config.logger,
|
||||
nodeDefinitionDirs: config.nodeDefinitionDirs,
|
||||
enableTextEditor: true,
|
||||
onTokenUsage: config.onTokenUsage,
|
||||
callbacks: config.callbacks,
|
||||
runMetadata: config.runMetadata,
|
||||
onTelemetryEvent: config.onTelemetryEvent,
|
||||
});
|
||||
|
||||
this.logger = config.logger;
|
||||
this.onGenerationSuccess = config.onGenerationSuccess;
|
||||
|
||||
// Initialize session handler if checkpointer is provided
|
||||
if (config.checkpointer) {
|
||||
this.sessionChatHandler = new SessionChatHandler({
|
||||
checkpointer: config.checkpointer,
|
||||
llm: config.llm,
|
||||
logger: config.logger,
|
||||
onGenerationSuccess: config.onGenerationSuccess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main chat method - generates workflow from user request
|
||||
*
|
||||
* @param payload - Chat payload with message and workflow context
|
||||
* @param userId - User ID for logging
|
||||
* @param abortSignal - Optional abort signal
|
||||
* @yields StreamOutput chunks for messages, tool progress, and workflow updates
|
||||
*/
|
||||
async *chat(
|
||||
payload: ChatPayload,
|
||||
userId: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
|
||||
this.logger?.debug('CodeWorkflowBuilder starting', {
|
||||
userId,
|
||||
workflowId,
|
||||
messageLength: payload.message.length,
|
||||
hasSessionHandler: !!this.sessionChatHandler,
|
||||
});
|
||||
|
||||
// Use session handler if available (handles onGenerationSuccess internally)
|
||||
if (this.sessionChatHandler) {
|
||||
yield* this.sessionChatHandler.execute({
|
||||
payload,
|
||||
userId,
|
||||
abortSignal,
|
||||
agentChat: (p, u, s, h) => this.codeBuilderAgent.chat(p, u, s, h),
|
||||
});
|
||||
} else {
|
||||
// No session handler - track generation success and call callback manually
|
||||
let generationSucceeded = false;
|
||||
|
||||
for await (const chunk of this.codeBuilderAgent.chat(payload, userId, abortSignal)) {
|
||||
// Check if this chunk indicates successful workflow generation
|
||||
if (chunk.messages?.some((msg) => msg.type === 'workflow-updated')) {
|
||||
generationSucceeded = true;
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
|
||||
if (generationSucceeded && this.onGenerationSuccess) {
|
||||
try {
|
||||
await this.onGenerationSuccess();
|
||||
} catch (error) {
|
||||
this.logger?.warn('Failed to execute onGenerationSuccess callback', { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a CodeWorkflowBuilder
|
||||
*/
|
||||
export function createCodeWorkflowBuilder(
|
||||
llm: BaseChatModel,
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
options?: {
|
||||
logger?: Logger;
|
||||
nodeDefinitionDirs?: string[];
|
||||
},
|
||||
): CodeWorkflowBuilder {
|
||||
return new CodeWorkflowBuilder({
|
||||
llm,
|
||||
nodeTypes,
|
||||
logger: options?.logger,
|
||||
nodeDefinitionDirs: options?.nodeDefinitionDirs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Constants for the Code Builder Agent
|
||||
*
|
||||
* Extracted from code-builder-agent.ts for better organization and testability.
|
||||
*/
|
||||
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
/** Maximum iterations for the agentic loop to prevent infinite loops */
|
||||
export const MAX_AGENT_ITERATIONS = 50;
|
||||
|
||||
/** Maximum validate attempts before giving up in text editor mode */
|
||||
export const MAX_VALIDATE_ATTEMPTS = 10;
|
||||
|
||||
/** Instruction appended to validation/parse error messages with steps to fix */
|
||||
export const FIX_VALIDATION_ERRORS_INSTRUCTION = `
|
||||
Use the think tool to analyze ALL errors at once, then act:
|
||||
1. Which errors are relevant to the last user request? If NONE, stop — do not fix unrelated warnings.
|
||||
2. Use search_nodes and get_node_types to look up the correct node schema (if not already fetched)
|
||||
3. Use batch_str_replace to fix ALL identified issues atomically in one call (preferred), or use individual str_replace/insert calls
|
||||
4. Call validate_workflow ONCE after all fixes are applied
|
||||
Do NOT output explanations — just fix the code. Do not add or edit comments.`;
|
||||
|
||||
/** Native Anthropic text editor tool configuration */
|
||||
export const TEXT_EDITOR_TOOL = {
|
||||
type: 'text_editor_20250728' as const,
|
||||
name: 'str_replace_based_edit_tool' as const,
|
||||
};
|
||||
|
||||
/** Batch str_replace tool - applies multiple replacements atomically in one call */
|
||||
export const BATCH_STR_REPLACE_TOOL = {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'batch_str_replace',
|
||||
description:
|
||||
'Apply multiple str_replace operations to /workflow.js atomically. All replacements are applied in order. If any replacement fails, all changes are rolled back. Example: {"replacements": [{"old_str": "foo", "new_str": "bar"}, {"old_str": "baz", "new_str": "qux"}]}',
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
replacements: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
old_str: { type: 'string' as const, description: 'The exact string to find' },
|
||||
new_str: { type: 'string' as const, description: 'The replacement string' },
|
||||
},
|
||||
required: ['old_str', 'new_str'],
|
||||
},
|
||||
description: 'Ordered list of replacements to apply',
|
||||
},
|
||||
},
|
||||
required: ['replacements'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Validate workflow tool schema - separate from text editor for clearer separation of concerns */
|
||||
export const VALIDATE_TOOL = {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'validate_workflow',
|
||||
description:
|
||||
'Validate the current workflow code for errors. Returns validation results - either success or a list of errors to fix.',
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string' as const,
|
||||
description: 'Path to the workflow file (must be /workflow.js)',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* CodeBuilderAgent tools for display when session is loaded
|
||||
*/
|
||||
export const CODE_BUILDER_TEXT_EDITOR_TOOL: BuilderToolBase = {
|
||||
toolName: 'str_replace_based_edit_tool',
|
||||
displayTitle: 'Editing workflow',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_BATCH_STR_REPLACE_TOOL: BuilderToolBase = {
|
||||
toolName: 'batch_str_replace',
|
||||
displayTitle: 'Editing workflow',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_VALIDATE_TOOL: BuilderToolBase = {
|
||||
toolName: 'validate_workflow',
|
||||
displayTitle: 'Validating workflow',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_SEARCH_NODES_TOOL: BuilderToolBase = {
|
||||
toolName: 'search_nodes',
|
||||
displayTitle: 'Searching nodes',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_GET_NODE_TYPES_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_node_types',
|
||||
displayTitle: 'Getting node definitions',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_GET_SUGGESTED_NODES_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_suggested_nodes',
|
||||
displayTitle: 'Getting suggested nodes',
|
||||
};
|
||||
|
||||
export const CODE_BUILDER_THINK_TOOL: BuilderToolBase = {
|
||||
toolName: 'think',
|
||||
displayTitle: 'Reasoning',
|
||||
};
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Code Builder Node Search Engine
|
||||
*
|
||||
* This is a fork of tools/engines/node-search-engine.ts for the code-builder agent.
|
||||
* It includes additional features for subnode requirements, related subnodes, and
|
||||
* builder hints that are specific to the code-builder workflow generation approach.
|
||||
*
|
||||
* The original node-search-engine.ts is used by the multi-agent system and has
|
||||
* different requirements.
|
||||
*/
|
||||
|
||||
import { sublimeSearch } from '@n8n/utils';
|
||||
import type { BuilderHintInputs, INodeTypeDescription, NodeConnectionType } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import type { CodeBuilderNodeSearchResult, SubnodeRequirement } from '../types';
|
||||
|
||||
/** Type guard for NodeConnectionType */
|
||||
function isNodeConnectionType(value: string): value is NodeConnectionType {
|
||||
return Object.values(NodeConnectionTypes).includes(value as NodeConnectionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default subnodes for each connection type
|
||||
* These are sensible defaults shown in search results
|
||||
*/
|
||||
const DEFAULT_SUBNODES: Record<string, string[]> = {
|
||||
ai_languageModel: ['@n8n/n8n-nodes-langchain.lmChatOpenAi'],
|
||||
ai_memory: ['@n8n/n8n-nodes-langchain.memoryBufferWindow'],
|
||||
ai_embedding: ['@n8n/n8n-nodes-langchain.embeddingsOpenAi'],
|
||||
ai_vectorStore: ['@n8n/n8n-nodes-langchain.vectorStoreInMemory'],
|
||||
// ai_tool is intentionally excluded - varies by use case
|
||||
};
|
||||
|
||||
/**
|
||||
* Search keys configuration for sublimeSearch
|
||||
* Keys are ordered by importance with corresponding weights
|
||||
*/
|
||||
const NODE_SEARCH_KEYS = [
|
||||
{ key: 'displayName', weight: 1.5 },
|
||||
{ key: 'name', weight: 1.3 },
|
||||
{ key: 'codex.alias', weight: 1.0 },
|
||||
{ key: 'description', weight: 0.7 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract the short type name from a full node name
|
||||
* e.g., "n8n-nodes-base.set" -> "set"
|
||||
*/
|
||||
function getTypeName(nodeName: string): string {
|
||||
if (!nodeName) return '';
|
||||
const lastDotIndex = nodeName.lastIndexOf('.');
|
||||
return lastDotIndex >= 0 ? nodeName.substring(lastDotIndex + 1) : nodeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoring weights for connection type matching
|
||||
*/
|
||||
export const SCORE_WEIGHTS = {
|
||||
CONNECTION_EXACT: 100,
|
||||
CONNECTION_IN_EXPRESSION: 50,
|
||||
} as const;
|
||||
|
||||
function getLatestVersion(version: number | number[]): number {
|
||||
return Array.isArray(version) ? Math.max(...version) : version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract subnode requirements from builderHint.inputs
|
||||
*/
|
||||
function extractSubnodeRequirements(inputs?: BuilderHintInputs): SubnodeRequirement[] {
|
||||
if (!inputs) return [];
|
||||
|
||||
return Object.entries(inputs).map(([connectionType, config]) => ({
|
||||
connectionType,
|
||||
required: config.required,
|
||||
...(config.displayOptions && { displayOptions: config.displayOptions }),
|
||||
}));
|
||||
}
|
||||
|
||||
function dedupeNodes(nodes: INodeTypeDescription[]): INodeTypeDescription[] {
|
||||
const dedupeCache: Record<string, INodeTypeDescription> = {};
|
||||
nodes.forEach((node) => {
|
||||
const cachedNodeType = dedupeCache[node.name];
|
||||
if (!cachedNodeType) {
|
||||
dedupeCache[node.name] = node;
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedVersion = getLatestVersion(cachedNodeType.version);
|
||||
const nextVersion = getLatestVersion(node.version);
|
||||
|
||||
if (nextVersion > cachedVersion) {
|
||||
dedupeCache[node.name] = node;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(dedupeCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure business logic for searching nodes
|
||||
* Separated from tool infrastructure for better testability
|
||||
*/
|
||||
export class CodeBuilderNodeSearchEngine {
|
||||
private readonly nodeTypes: INodeTypeDescription[];
|
||||
constructor(nodeTypes: INodeTypeDescription[]) {
|
||||
this.nodeTypes = dedupeNodes(nodeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search nodes by name, display name, or description
|
||||
* Always return the latest version of a node
|
||||
* @param query - The search query string
|
||||
* @param limit - Maximum number of results to return
|
||||
* @returns Array of matching nodes sorted by relevance
|
||||
*/
|
||||
searchByName(query: string, limit: number = 20): CodeBuilderNodeSearchResult[] {
|
||||
// Use sublimeSearch for fuzzy matching
|
||||
const searchResults = sublimeSearch<INodeTypeDescription>(
|
||||
query,
|
||||
this.nodeTypes,
|
||||
NODE_SEARCH_KEYS,
|
||||
);
|
||||
|
||||
const queryLower = query.toLowerCase().trim();
|
||||
const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name));
|
||||
|
||||
// Direct type name match on all nodeTypes (catches nodes sublimeSearch ranked too low)
|
||||
const typeNameMatches = this.nodeTypes
|
||||
.filter((node) => {
|
||||
if (fuzzyResultNames.has(node.name)) return false;
|
||||
return getTypeName(node.name).toLowerCase() === queryLower;
|
||||
})
|
||||
.map((item) => ({ item, score: 0 }));
|
||||
|
||||
// Merge and sort: exact type name matches first, then by fuzzy score
|
||||
const allResults = [...searchResults, ...typeNameMatches];
|
||||
allResults.sort((a, b) => {
|
||||
const exactA = getTypeName(a.item.name).toLowerCase() === queryLower;
|
||||
const exactB = getTypeName(b.item.name).toLowerCase() === queryLower;
|
||||
if (exactA && !exactB) return -1;
|
||||
if (!exactA && exactB) return 1;
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
// Apply limit and map to result format
|
||||
return allResults
|
||||
.slice(0, limit)
|
||||
.map(
|
||||
({
|
||||
item,
|
||||
score,
|
||||
}: { item: INodeTypeDescription; score: number }): CodeBuilderNodeSearchResult => {
|
||||
const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs);
|
||||
return {
|
||||
name: item.name,
|
||||
displayName: item.displayName,
|
||||
description: item.description ?? 'No description available',
|
||||
version: getLatestVersion(item.version),
|
||||
inputs: item.inputs,
|
||||
outputs: item.outputs,
|
||||
score,
|
||||
...(item.builderHint?.message && { builderHintMessage: item.builderHint.message }),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for sub-nodes that output a specific connection type
|
||||
* Always return the latest version of a node
|
||||
* @param connectionType - The connection type to search for
|
||||
* @param limit - Maximum number of results
|
||||
* @param nameFilter - Optional name filter
|
||||
* @returns Array of matching sub-nodes
|
||||
*/
|
||||
searchByConnectionType(
|
||||
connectionType: NodeConnectionType,
|
||||
limit: number = 20,
|
||||
nameFilter?: string,
|
||||
): CodeBuilderNodeSearchResult[] {
|
||||
// First, filter by connection type
|
||||
const nodesWithConnectionType = this.nodeTypes
|
||||
.map((nodeType) => {
|
||||
const connectionScore = this.getConnectionScore(nodeType, connectionType);
|
||||
return connectionScore > 0 ? { nodeType, connectionScore } : null;
|
||||
})
|
||||
.filter((result): result is { nodeType: INodeTypeDescription; connectionScore: number } =>
|
||||
Boolean(result),
|
||||
);
|
||||
|
||||
// If no name filter, return connection matches sorted by score
|
||||
if (!nameFilter) {
|
||||
return nodesWithConnectionType
|
||||
.sort((a, b) => b.connectionScore - a.connectionScore)
|
||||
.slice(0, limit)
|
||||
.map(({ nodeType, connectionScore }) => {
|
||||
const subnodeRequirements = extractSubnodeRequirements(nodeType.builderHint?.inputs);
|
||||
return {
|
||||
name: nodeType.name,
|
||||
displayName: nodeType.displayName,
|
||||
version: getLatestVersion(nodeType.version),
|
||||
description: nodeType.description ?? 'No description available',
|
||||
inputs: nodeType.inputs,
|
||||
outputs: nodeType.outputs,
|
||||
score: connectionScore,
|
||||
...(nodeType.builderHint?.message && {
|
||||
builderHintMessage: nodeType.builderHint.message,
|
||||
}),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Apply name filter using sublimeSearch
|
||||
const nodeTypesOnly = nodesWithConnectionType.map((result) => result.nodeType);
|
||||
const nameFilteredResults = sublimeSearch(nameFilter, nodeTypesOnly, NODE_SEARCH_KEYS);
|
||||
|
||||
// Combine connection score with name score
|
||||
return nameFilteredResults
|
||||
.slice(0, limit)
|
||||
.map(({ item, score: nameScore }: { item: INodeTypeDescription; score: number }) => {
|
||||
const connectionResult = nodesWithConnectionType.find(
|
||||
(result) => result.nodeType.name === item.name,
|
||||
);
|
||||
const connectionScore = connectionResult?.connectionScore ?? 0;
|
||||
const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs);
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
version: getLatestVersion(item.version),
|
||||
displayName: item.displayName,
|
||||
description: item.description ?? 'No description available',
|
||||
inputs: item.inputs,
|
||||
outputs: item.outputs,
|
||||
score: connectionScore + nameScore,
|
||||
...(item.builderHint?.message && { builderHintMessage: item.builderHint.message }),
|
||||
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for tool output
|
||||
* @param result - Single search result
|
||||
* @returns XML-formatted string
|
||||
*/
|
||||
formatResult(result: CodeBuilderNodeSearchResult): string {
|
||||
const parts = [
|
||||
' <node>',
|
||||
` <node_name>${result.name}</node_name>`,
|
||||
` <node_version>${result.version}</node_version>`,
|
||||
` <node_description>${result.description}</node_description>`,
|
||||
` <node_inputs>${typeof result.inputs === 'object' ? JSON.stringify(result.inputs) : result.inputs}</node_inputs>`,
|
||||
` <node_outputs>${typeof result.outputs === 'object' ? JSON.stringify(result.outputs) : result.outputs}</node_outputs>`,
|
||||
];
|
||||
|
||||
// Add builder hint message if present
|
||||
if (result.builderHintMessage) {
|
||||
parts.push(` <builder_hint>${result.builderHintMessage}</builder_hint>`);
|
||||
}
|
||||
|
||||
// Add subnode requirements if present
|
||||
if (result.subnodeRequirements && result.subnodeRequirements.length > 0) {
|
||||
parts.push(' <subnode_requirements>');
|
||||
for (const req of result.subnodeRequirements) {
|
||||
const requiredStr = req.required ? 'required' : 'optional';
|
||||
if (req.displayOptions) {
|
||||
parts.push(
|
||||
` <requirement type="${req.connectionType}" status="${requiredStr}"><display_options>${JSON.stringify(req.displayOptions)}</display_options></requirement>`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
' <requirement type="' +
|
||||
req.connectionType +
|
||||
'" status="' +
|
||||
requiredStr +
|
||||
'" />',
|
||||
);
|
||||
}
|
||||
}
|
||||
parts.push(' </subnode_requirements>');
|
||||
}
|
||||
|
||||
parts.push(' </node>');
|
||||
|
||||
return '\n' + parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subnodes that output a specific connection type
|
||||
* Uses default subnodes for common connection types
|
||||
* @param connectionType - The connection type to find subnodes for
|
||||
* @returns Array of node IDs that can satisfy this connection type
|
||||
*/
|
||||
getSubnodesForConnectionType(connectionType: string): string[] {
|
||||
// Return defaults for this connection type if available
|
||||
// ai_tool is excluded - it varies by use case
|
||||
return DEFAULT_SUBNODES[connectionType] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect related subnode IDs for nodes with builderHint.inputs
|
||||
* For each connection type requirement, finds default subnodes and their transitive dependencies
|
||||
* @param nodeIds - Initial node IDs to get related subnodes for
|
||||
* @param excludeNodeIds - Node IDs to exclude (already shown in results)
|
||||
* @returns Set of related node IDs
|
||||
*/
|
||||
getRelatedSubnodeIds(nodeIds: string[], excludeNodeIds: Set<string>): Set<string> {
|
||||
const allRelated = new Set<string>();
|
||||
const visited = new Set<string>(excludeNodeIds);
|
||||
|
||||
// Mark initial nodes as visited
|
||||
for (const nodeId of nodeIds) {
|
||||
visited.add(nodeId);
|
||||
}
|
||||
|
||||
// Process queue of nodes to check for subnode requirements
|
||||
const queue = [...nodeIds];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentNodeId = queue.shift()!;
|
||||
const nodeType = this.nodeTypes.find((n) => n.name === currentNodeId);
|
||||
if (!nodeType?.builderHint?.inputs) continue;
|
||||
|
||||
// For each connection type in builderHint.inputs, get default subnodes
|
||||
for (const connectionType of Object.keys(nodeType.builderHint.inputs)) {
|
||||
const subnodeIds = this.getSubnodesForConnectionType(connectionType);
|
||||
|
||||
for (const subnodeId of subnodeIds) {
|
||||
if (visited.has(subnodeId)) continue;
|
||||
|
||||
visited.add(subnodeId);
|
||||
allRelated.add(subnodeId);
|
||||
|
||||
// Add to queue for recursive processing
|
||||
queue.push(subnodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allRelated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node type by ID
|
||||
* @param nodeId - The node ID to look up
|
||||
* @returns The node type description or undefined
|
||||
*/
|
||||
getNodeType(nodeId: string): INodeTypeDescription | undefined {
|
||||
return this.nodeTypes.find((n) => n.name === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node has a specific connection type in outputs
|
||||
* @param nodeType - Node type to check
|
||||
* @param connectionType - Connection type to look for
|
||||
* @returns Score indicating match quality
|
||||
*/
|
||||
private getConnectionScore(
|
||||
nodeType: INodeTypeDescription,
|
||||
connectionType: NodeConnectionType,
|
||||
): number {
|
||||
const outputs = nodeType.outputs;
|
||||
|
||||
if (Array.isArray(outputs)) {
|
||||
// Direct array match
|
||||
if (outputs.includes(connectionType)) {
|
||||
return SCORE_WEIGHTS.CONNECTION_EXACT;
|
||||
}
|
||||
} else if (typeof outputs === 'string') {
|
||||
// Expression string - check if it contains the connection type
|
||||
if (outputs.includes(connectionType)) {
|
||||
return SCORE_WEIGHTS.CONNECTION_IN_EXPRESSION;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a connection type is an AI connection type
|
||||
* @param connectionType - Connection type to validate
|
||||
* @returns True if it's an AI connection type
|
||||
*/
|
||||
static isAiConnectionType(connectionType: string): boolean {
|
||||
return connectionType.startsWith('ai_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available AI connection types
|
||||
* @returns Array of AI connection types
|
||||
*/
|
||||
static getAiConnectionTypes(): NodeConnectionType[] {
|
||||
return Object.values(NodeConnectionTypes).filter(
|
||||
(type): type is NodeConnectionType =>
|
||||
isNodeConnectionType(type) && CodeBuilderNodeSearchEngine.isAiConnectionType(type),
|
||||
);
|
||||
}
|
||||
}
|
||||
+902
@@ -0,0 +1,902 @@
|
||||
/**
|
||||
* Tests for Code Builder Node Search Engine
|
||||
*
|
||||
* This is a fork of tools/engines/test/node-search-engine.test.ts for the code-builder agent.
|
||||
* It tests the additional features specific to the code-builder workflow generation approach.
|
||||
*/
|
||||
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type INodeTypeDescription,
|
||||
type NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { createNodeType } from '../../../../test/test-utils';
|
||||
import { CodeBuilderNodeSearchEngine, SCORE_WEIGHTS } from '../code-builder-node-search-engine';
|
||||
|
||||
describe('CodeBuilderNodeSearchEngine', () => {
|
||||
let searchEngine: CodeBuilderNodeSearchEngine;
|
||||
let nodeTypes: INodeTypeDescription[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a diverse set of test node types
|
||||
nodeTypes = [
|
||||
createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
description: 'Makes HTTP requests to external services',
|
||||
group: ['input'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
description: 'Run custom JavaScript code',
|
||||
group: ['transform'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: 'n8n-nodes-base.webhook',
|
||||
displayName: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
group: ['trigger'],
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
description: 'Language model from OpenAI',
|
||||
group: ['output'],
|
||||
inputs: [],
|
||||
outputs: ['ai_languageModel'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
displayName: 'Calculator Tool',
|
||||
description: 'Perform mathematical calculations',
|
||||
group: ['output'],
|
||||
inputs: [],
|
||||
outputs: ['ai_tool'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.vectorStoreMemory',
|
||||
displayName: 'Vector Store Memory',
|
||||
description: 'Store and retrieve embeddings',
|
||||
group: ['output'],
|
||||
inputs: [],
|
||||
outputs: ['ai_memory'],
|
||||
}),
|
||||
createNodeType({
|
||||
name: 'n8n-nodes-base.httpBin',
|
||||
displayName: 'HTTP Bin',
|
||||
description: 'Test HTTP requests with httpbin.org',
|
||||
group: ['input'],
|
||||
codex: {
|
||||
alias: ['httpbin', 'request bin'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
searchEngine = new CodeBuilderNodeSearchEngine(nodeTypes);
|
||||
});
|
||||
|
||||
describe('searchByName', () => {
|
||||
it('should find nodes by exact name match', () => {
|
||||
const results = searchEngine.searchByName('Code');
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
// First result should be the Code node with highest score
|
||||
expect(results[0].name).toBe('n8n-nodes-base.code');
|
||||
expect(results[0].displayName).toBe('Code');
|
||||
// Should have a positive score from sublimeSearch fuzzy matching
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find nodes by partial name match', () => {
|
||||
const results = searchEngine.searchByName('http');
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
const httpRequestNode = results.find((r) => r.name === 'n8n-nodes-base.httpRequest');
|
||||
const httpBinNode = results.find((r) => r.name === 'n8n-nodes-base.httpBin');
|
||||
expect(httpRequestNode).toBeDefined();
|
||||
expect(httpBinNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('should find nodes by description match', () => {
|
||||
const results = searchEngine.searchByName('javascript');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('n8n-nodes-base.code');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find nodes by alias match', () => {
|
||||
const results = searchEngine.searchByName('httpbin');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('n8n-nodes-base.httpBin');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive search', () => {
|
||||
const resultsLower = searchEngine.searchByName('webhook');
|
||||
const resultsUpper = searchEngine.searchByName('WEBHOOK');
|
||||
const resultsMixed = searchEngine.searchByName('WebHook');
|
||||
|
||||
expect(resultsLower).toHaveLength(1);
|
||||
expect(resultsUpper).toHaveLength(1);
|
||||
expect(resultsMixed).toHaveLength(1);
|
||||
expect(resultsLower[0].name).toBe(resultsUpper[0].name);
|
||||
expect(resultsLower[0].name).toBe(resultsMixed[0].name);
|
||||
});
|
||||
|
||||
it('should return empty array for no matches', () => {
|
||||
const results = searchEngine.searchByName('nonexistent');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', () => {
|
||||
const results = searchEngine.searchByName('a', 2);
|
||||
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should combine scores for multiple matches', () => {
|
||||
const results = searchEngine.searchByName('request');
|
||||
|
||||
// HTTP Request should have highest score (name + display name + description)
|
||||
expect(results[0].name).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle nodes without description', () => {
|
||||
const nodeWithoutDesc = createNodeType({
|
||||
name: 'test.node',
|
||||
displayName: 'Test Node',
|
||||
description: undefined,
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeWithoutDesc]);
|
||||
|
||||
const results = engine.searchByName('test');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].description).toBe('No description available');
|
||||
});
|
||||
|
||||
it('should handle malformed node types gracefully', () => {
|
||||
// Add a node that might cause errors
|
||||
const malformedNode = {
|
||||
name: null as unknown as string,
|
||||
displayName: undefined as unknown as string,
|
||||
} as INodeTypeDescription;
|
||||
|
||||
const engineWithMalformed = new CodeBuilderNodeSearchEngine([...nodeTypes, malformedNode]);
|
||||
|
||||
// Should not throw and should return valid results
|
||||
expect(() => engineWithMalformed.searchByName('http')).not.toThrow();
|
||||
const results = engineWithMalformed.searchByName('http');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByName - type name priority', () => {
|
||||
it('should return node with matching type name as first result', () => {
|
||||
const setNode = createNodeType({
|
||||
name: 'n8n-nodes-base.set',
|
||||
displayName: 'Edit Fields',
|
||||
description: 'Modify, add, or remove item fields',
|
||||
group: ['transform'],
|
||||
});
|
||||
const settingsNode = createNodeType({
|
||||
name: 'n8n-nodes-base.settingsManager',
|
||||
displayName: 'Settings Manager',
|
||||
description: 'Manage application settings',
|
||||
group: ['transform'],
|
||||
});
|
||||
const datasetNode = createNodeType({
|
||||
name: 'n8n-nodes-base.dataset',
|
||||
displayName: 'Dataset',
|
||||
description: 'Work with datasets',
|
||||
group: ['transform'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([
|
||||
...nodeTypes,
|
||||
setNode,
|
||||
settingsNode,
|
||||
datasetNode,
|
||||
]);
|
||||
|
||||
const results = engine.searchByName('set', 5);
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].name).toBe('n8n-nodes-base.set');
|
||||
expect(results[0].displayName).toBe('Edit Fields');
|
||||
});
|
||||
|
||||
it('should return type name match first even with small limit', () => {
|
||||
const setNode = createNodeType({
|
||||
name: 'n8n-nodes-base.set',
|
||||
displayName: 'Edit Fields',
|
||||
description: 'Modify, add, or remove item fields',
|
||||
group: ['transform'],
|
||||
});
|
||||
const competitors = Array.from({ length: 10 }, (_, i) =>
|
||||
createNodeType({
|
||||
name: `n8n-nodes-base.setter${i}`,
|
||||
displayName: `Setter Node ${i}`,
|
||||
description: `Sets values for configuration ${i}`,
|
||||
group: ['transform'],
|
||||
}),
|
||||
);
|
||||
const engine = new CodeBuilderNodeSearchEngine([...competitors, setNode]);
|
||||
|
||||
const results = engine.searchByName('set', 3);
|
||||
|
||||
expect(results[0].name).toBe('n8n-nodes-base.set');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchByConnectionType', () => {
|
||||
it('should find nodes with exact connection type match', () => {
|
||||
const results = searchEngine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('@n8n/n8n-nodes-langchain.toolCalculator');
|
||||
expect(results[0].score).toBe(SCORE_WEIGHTS.CONNECTION_EXACT);
|
||||
});
|
||||
|
||||
it('should find multiple nodes with same connection type', () => {
|
||||
// Add another AI tool node
|
||||
const anotherTool = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.toolCode',
|
||||
displayName: 'Code Tool',
|
||||
outputs: ['ai_tool'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, anotherTool]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((r) => r.name)).toContain('@n8n/n8n-nodes-langchain.toolCalculator');
|
||||
expect(results.map((r) => r.name)).toContain('@n8n/n8n-nodes-langchain.toolCode');
|
||||
});
|
||||
|
||||
it('should apply name filter when provided', () => {
|
||||
const results = searchEngine.searchByConnectionType(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
20,
|
||||
'openai',
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('@n8n/n8n-nodes-langchain.lmChatOpenAi');
|
||||
});
|
||||
|
||||
it('should exclude nodes that do not match name filter', () => {
|
||||
const results = searchEngine.searchByConnectionType(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
20,
|
||||
'anthropic',
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle expression-based outputs', () => {
|
||||
const expressionNode = createNodeType({
|
||||
name: 'test.expression',
|
||||
displayName: 'Expression Node',
|
||||
outputs: '={{ $parameter.mode === "tool" ? "ai_tool" : "main" }}',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, expressionNode]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
// Should find the expression node with lower score
|
||||
const expressionResult = results.find((r) => r.name === 'test.expression');
|
||||
expect(expressionResult).toBeDefined();
|
||||
expect(expressionResult!.score).toBe(SCORE_WEIGHTS.CONNECTION_IN_EXPRESSION);
|
||||
});
|
||||
|
||||
it('should sort by score with name filter boost', () => {
|
||||
// Add nodes with different match qualities
|
||||
const exactMatch = createNodeType({
|
||||
name: 'test.exact',
|
||||
displayName: 'Calculator Exact',
|
||||
outputs: ['ai_tool'],
|
||||
});
|
||||
const expressionMatch = createNodeType({
|
||||
name: 'test.expression',
|
||||
displayName: 'Something Else',
|
||||
outputs: '={{ "ai_tool" }}',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, exactMatch, expressionMatch]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 20, 'calculator');
|
||||
|
||||
// Both Calculator nodes should appear (both have 'calculator' in name)
|
||||
// Expression match should not appear (no name match)
|
||||
const names = results.map((r) => r.name);
|
||||
expect(names).toContain('test.exact');
|
||||
expect(names).toContain('@n8n/n8n-nodes-langchain.toolCalculator');
|
||||
expect(results.find((r) => r.name === 'test.expression')).toBeUndefined();
|
||||
|
||||
// Both should have same score (exact connection + name match)
|
||||
const exactScore = results.find((r) => r.name === 'test.exact')?.score;
|
||||
const calculatorScore = results.find(
|
||||
(r) => r.name === '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
)?.score;
|
||||
expect(exactScore).toBeDefined();
|
||||
expect(calculatorScore).toBeDefined();
|
||||
expect(exactScore).toBeGreaterThan(0);
|
||||
expect(calculatorScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent connection type', () => {
|
||||
const results = searchEngine.searchByConnectionType('ai_nonexistent' as NodeConnectionType);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', () => {
|
||||
// Add many nodes with same connection type
|
||||
const manyNodes = Array.from({ length: 10 }, (_, i) =>
|
||||
createNodeType({
|
||||
name: `test.tool${i}`,
|
||||
displayName: `Tool ${i}`,
|
||||
outputs: ['ai_tool'],
|
||||
}),
|
||||
);
|
||||
const engine = new CodeBuilderNodeSearchEngine([...nodeTypes, ...manyNodes]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiTool, 5);
|
||||
|
||||
expect(results).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatResult', () => {
|
||||
it('should format search result as XML', () => {
|
||||
const result = {
|
||||
name: 'test.node',
|
||||
displayName: 'Test Node',
|
||||
description: 'Test description',
|
||||
version: 1,
|
||||
inputs: ['main'] as NodeConnectionType[],
|
||||
outputs: ['main'] as NodeConnectionType[],
|
||||
score: 100,
|
||||
};
|
||||
|
||||
const formatted = searchEngine.formatResult(result);
|
||||
|
||||
expect(formatted).toContain('<node>');
|
||||
expect(formatted).toContain('<node_name>test.node</node_name>');
|
||||
expect(formatted).toContain('<node_description>Test description</node_description>');
|
||||
expect(formatted).toContain('</node>');
|
||||
});
|
||||
|
||||
it('should handle array inputs/outputs', () => {
|
||||
const result = {
|
||||
name: 'test.node',
|
||||
displayName: 'Test Node',
|
||||
description: 'Test',
|
||||
version: 1,
|
||||
inputs: ['main', 'ai_tool'] as NodeConnectionType[],
|
||||
outputs: ['main', 'main'] as NodeConnectionType[],
|
||||
score: 50,
|
||||
};
|
||||
|
||||
const formatted = searchEngine.formatResult(result);
|
||||
|
||||
expect(formatted).toContain('<node_inputs>["main","ai_tool"]</node_inputs>');
|
||||
expect(formatted).toContain('<node_outputs>["main","main"]</node_outputs>');
|
||||
});
|
||||
|
||||
it('should handle string inputs/outputs', () => {
|
||||
const result = {
|
||||
name: 'test.node',
|
||||
displayName: 'Test Node',
|
||||
description: 'Test',
|
||||
version: 1,
|
||||
inputs: '={{ $parameter.inputs }}' as `={{${string}}}`,
|
||||
outputs: '={{ $parameter.outputs }}' as `={{${string}}}`,
|
||||
score: 50,
|
||||
};
|
||||
|
||||
const formatted = searchEngine.formatResult(result);
|
||||
|
||||
expect(formatted).toContain('<node_inputs>={{ $parameter.inputs }}</node_inputs>');
|
||||
expect(formatted).toContain('<node_outputs>={{ $parameter.outputs }}</node_outputs>');
|
||||
});
|
||||
|
||||
it('should handle complex object inputs/outputs', () => {
|
||||
const result = {
|
||||
name: 'test.node',
|
||||
displayName: 'Test Node',
|
||||
description: 'Test',
|
||||
version: 1,
|
||||
inputs: [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{ type: NodeConnectionTypes.AiTool, required: false },
|
||||
],
|
||||
outputs: [{ type: NodeConnectionTypes.Main }],
|
||||
score: 50,
|
||||
};
|
||||
|
||||
const formatted = searchEngine.formatResult(result);
|
||||
|
||||
expect(formatted).toContain(
|
||||
'<node_inputs>[{"type":"main"},{"type":"ai_tool","required":false}]</node_inputs>',
|
||||
);
|
||||
expect(formatted).toContain('<node_outputs>[{"type":"main"}]</node_outputs>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('static methods', () => {
|
||||
it('should identify AI connection types', () => {
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_tool')).toBe(true);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('ai_languageModel')).toBe(true);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('main')).toBe(false);
|
||||
expect(CodeBuilderNodeSearchEngine.isAiConnectionType('Main')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all AI connection types', () => {
|
||||
const aiTypes = CodeBuilderNodeSearchEngine.getAiConnectionTypes();
|
||||
|
||||
expect(aiTypes).toContain(NodeConnectionTypes.AiTool);
|
||||
expect(aiTypes).toContain(NodeConnectionTypes.AiLanguageModel);
|
||||
expect(aiTypes).toContain(NodeConnectionTypes.AiMemory);
|
||||
expect(aiTypes).not.toContain(NodeConnectionTypes.Main);
|
||||
|
||||
// All should start with 'ai_'
|
||||
aiTypes.forEach((type) => {
|
||||
expect(type).toMatch(/^ai_/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle empty node types array', () => {
|
||||
const emptyEngine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
expect(emptyEngine.searchByName('test')).toEqual([]);
|
||||
expect(emptyEngine.searchByConnectionType(NodeConnectionTypes.AiTool)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined/null in node properties', () => {
|
||||
const nodeWithNulls = createNodeType({
|
||||
name: 'test.nulls',
|
||||
displayName: 'Null Test',
|
||||
description: null as unknown as string,
|
||||
codex: {
|
||||
alias: null as unknown as string[],
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeWithNulls]);
|
||||
|
||||
expect(() => engine.searchByName('null')).not.toThrow();
|
||||
const results = engine.searchByName('null');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle very long queries', () => {
|
||||
const longQuery = 'a'.repeat(1000);
|
||||
|
||||
expect(() => searchEngine.searchByName(longQuery)).not.toThrow();
|
||||
expect(() =>
|
||||
searchEngine.searchByConnectionType(NodeConnectionTypes.AiTool, 20, longQuery),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle special characters in queries', () => {
|
||||
const specialChars = ['(', ')', '[', ']', '{', '}', '.', '*', '+', '?', '^', '$', '|', '\\'];
|
||||
|
||||
specialChars.forEach((char) => {
|
||||
expect(() => searchEngine.searchByName(char)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('node version deduplication', () => {
|
||||
it('should deduplicate nodes with same name but different versions', () => {
|
||||
const nodeV1 = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request V1',
|
||||
version: 1,
|
||||
});
|
||||
const nodeV2 = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request V2',
|
||||
version: 2,
|
||||
});
|
||||
const nodeV3 = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request V3',
|
||||
version: 3,
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2, nodeV3]);
|
||||
const results = engine.searchByName('http');
|
||||
|
||||
// Should only return one node (the latest version)
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.version).toBe(3);
|
||||
expect(results[0]?.displayName).toBe('HTTP Request V3');
|
||||
});
|
||||
|
||||
it('should handle array versions and return latest', () => {
|
||||
const nodeV1 = createNodeType({
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code V1',
|
||||
version: 1,
|
||||
});
|
||||
const nodeV2V3 = createNodeType({
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code V2-3',
|
||||
version: [2, 3],
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1, nodeV2V3]);
|
||||
const results = engine.searchByName('code');
|
||||
|
||||
// Should return the node with array version [2, 3] as latest
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.version).toBe(3);
|
||||
expect(results[0]?.displayName).toBe('Code V2-3');
|
||||
});
|
||||
|
||||
it('should return latest version when comparing single version vs array version', () => {
|
||||
const nodeV1V2 = createNodeType({
|
||||
name: 'n8n-nodes-base.webhook',
|
||||
displayName: 'Webhook V1-2',
|
||||
version: [1, 2],
|
||||
});
|
||||
const nodeV3 = createNodeType({
|
||||
name: 'n8n-nodes-base.webhook',
|
||||
displayName: 'Webhook V3',
|
||||
version: 3,
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([nodeV1V2, nodeV3]);
|
||||
const results = engine.searchByName('webhook');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.version).toBe(3);
|
||||
expect(results[0]?.displayName).toBe('Webhook V3');
|
||||
});
|
||||
|
||||
it('should include version field in all search results', () => {
|
||||
const results = searchEngine.searchByName('code');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((result) => {
|
||||
expect(result.version).toBeDefined();
|
||||
expect(typeof result.version).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include version field in connection type search results', () => {
|
||||
const results = searchEngine.searchByConnectionType(NodeConnectionTypes.AiTool);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
results.forEach((result) => {
|
||||
expect(result.version).toBeDefined();
|
||||
expect(typeof result.version).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include version in formatted XML output', () => {
|
||||
const results = searchEngine.searchByName('code', 1);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
const formatted = searchEngine.formatResult(results[0]);
|
||||
expect(formatted).toContain('<node_version>');
|
||||
expect(formatted).toMatch(/<node_version>\d+<\/node_version>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subnode requirements', () => {
|
||||
it('should extract subnode requirements from builderHint.inputs', () => {
|
||||
const agentNode = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_memory: { required: false },
|
||||
ai_tool: { required: false },
|
||||
ai_outputParser: {
|
||||
required: false,
|
||||
displayOptions: { show: { hasOutputParser: [true] } },
|
||||
},
|
||||
},
|
||||
message: 'Use with output parser for structured output',
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode]);
|
||||
|
||||
const results = engine.searchByName('agent');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].subnodeRequirements).toBeDefined();
|
||||
expect(results[0].subnodeRequirements).toHaveLength(4);
|
||||
|
||||
// Check required language model
|
||||
const lmReq = results[0].subnodeRequirements!.find(
|
||||
(r) => r.connectionType === 'ai_languageModel',
|
||||
);
|
||||
expect(lmReq).toBeDefined();
|
||||
expect(lmReq!.required).toBe(true);
|
||||
|
||||
// Check optional memory
|
||||
const memReq = results[0].subnodeRequirements!.find((r) => r.connectionType === 'ai_memory');
|
||||
expect(memReq).toBeDefined();
|
||||
expect(memReq!.required).toBe(false);
|
||||
|
||||
// Check conditional output parser
|
||||
const parserReq = results[0].subnodeRequirements!.find(
|
||||
(r) => r.connectionType === 'ai_outputParser',
|
||||
);
|
||||
expect(parserReq).toBeDefined();
|
||||
expect(parserReq!.required).toBe(false);
|
||||
expect(parserReq!.displayOptions).toEqual({ show: { hasOutputParser: [true] } });
|
||||
|
||||
// Check builder hint message
|
||||
expect(results[0].builderHintMessage).toBe('Use with output parser for structured output');
|
||||
});
|
||||
|
||||
it('should not include subnodeRequirements for nodes without builderHint.inputs', () => {
|
||||
const basicNode = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
|
||||
const results = engine.searchByName('http');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].subnodeRequirements).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include subnodeRequirements in searchByConnectionType results', () => {
|
||||
const openAiModel = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
outputs: ['ai_languageModel'],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_embedding: { required: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([openAiModel]);
|
||||
|
||||
const results = engine.searchByConnectionType(NodeConnectionTypes.AiLanguageModel);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].subnodeRequirements).toBeDefined();
|
||||
expect(results[0].subnodeRequirements).toHaveLength(1);
|
||||
expect(results[0].subnodeRequirements![0].connectionType).toBe('ai_embedding');
|
||||
});
|
||||
|
||||
it('should format subnode requirements in XML output', () => {
|
||||
const agentNode = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_outputParser: {
|
||||
required: false,
|
||||
displayOptions: { show: { hasOutputParser: [true] } },
|
||||
},
|
||||
},
|
||||
message: 'Test hint message',
|
||||
},
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode]);
|
||||
|
||||
const results = engine.searchByName('agent');
|
||||
const formatted = engine.formatResult(results[0]);
|
||||
|
||||
// Check builder hint
|
||||
expect(formatted).toContain('<builder_hint>Test hint message</builder_hint>');
|
||||
|
||||
// Check subnode requirements section
|
||||
expect(formatted).toContain('<subnode_requirements>');
|
||||
expect(formatted).toContain('type="ai_languageModel"');
|
||||
expect(formatted).toContain('status="required"');
|
||||
expect(formatted).toContain('type="ai_outputParser"');
|
||||
expect(formatted).toContain('status="optional"');
|
||||
expect(formatted).toContain('<display_options>');
|
||||
expect(formatted).toContain('hasOutputParser');
|
||||
expect(formatted).toContain('</subnode_requirements>');
|
||||
});
|
||||
|
||||
it('should not include subnode_requirements in XML for nodes without inputs', () => {
|
||||
const basicNode = createNodeType({
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
|
||||
const results = engine.searchByName('code');
|
||||
const formatted = engine.formatResult(results[0]);
|
||||
|
||||
expect(formatted).not.toContain('<subnode_requirements>');
|
||||
expect(formatted).not.toContain('<builder_hint>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubnodesForConnectionType', () => {
|
||||
it('should return default subnodes for ai_languageModel', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_languageModel');
|
||||
|
||||
expect(subnodes).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi');
|
||||
});
|
||||
|
||||
it('should return default subnodes for ai_memory', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_memory');
|
||||
|
||||
expect(subnodes).toContain('@n8n/n8n-nodes-langchain.memoryBufferWindow');
|
||||
});
|
||||
|
||||
it('should return default subnodes for ai_embedding', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_embedding');
|
||||
|
||||
expect(subnodes).toContain('@n8n/n8n-nodes-langchain.embeddingsOpenAi');
|
||||
});
|
||||
|
||||
it('should return empty array for ai_tool (varies by use case)', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('ai_tool');
|
||||
|
||||
expect(subnodes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown connection type', () => {
|
||||
const engine = new CodeBuilderNodeSearchEngine([]);
|
||||
|
||||
const subnodes = engine.getSubnodesForConnectionType('unknown_type');
|
||||
|
||||
expect(subnodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelatedSubnodeIds', () => {
|
||||
it('should find related subnodes based on builderHint.inputs', () => {
|
||||
const agentNode = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
ai_memory: { required: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
const openAiModel = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
outputs: ['ai_languageModel'],
|
||||
});
|
||||
const bufferMemory = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
displayName: 'Window Buffer Memory',
|
||||
outputs: ['ai_memory'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel, bufferMemory]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(['@n8n/n8n-nodes-langchain.agent'], new Set());
|
||||
|
||||
expect(relatedIds.has('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(true);
|
||||
expect(relatedIds.has('@n8n/n8n-nodes-langchain.memoryBufferWindow')).toBe(true);
|
||||
});
|
||||
|
||||
it('should recursively find nested subnode requirements', () => {
|
||||
// Create a chain: Vector Store Memory -> In-Memory Vector Store -> OpenAI Embeddings
|
||||
const vectorMemory = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.memoryVectorStore',
|
||||
displayName: 'Vector Store Memory',
|
||||
outputs: ['ai_memory'],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_vectorStore: { required: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const inMemoryVectorStore = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.vectorStoreInMemory',
|
||||
displayName: 'In-Memory Vector Store',
|
||||
outputs: ['ai_vectorStore'],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_embedding: { required: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const openAiEmbeddings = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.embeddingsOpenAi',
|
||||
displayName: 'OpenAI Embeddings',
|
||||
outputs: ['ai_embedding'],
|
||||
});
|
||||
|
||||
const engine = new CodeBuilderNodeSearchEngine([
|
||||
vectorMemory,
|
||||
inMemoryVectorStore,
|
||||
openAiEmbeddings,
|
||||
]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(
|
||||
['@n8n/n8n-nodes-langchain.memoryVectorStore'],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Should find the in-memory vector store (direct requirement)
|
||||
expect(relatedIds.has('@n8n/n8n-nodes-langchain.vectorStoreInMemory')).toBe(true);
|
||||
// Should also find OpenAI embeddings (nested requirement of vector store)
|
||||
expect(relatedIds.has('@n8n/n8n-nodes-langchain.embeddingsOpenAi')).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude already shown node IDs', () => {
|
||||
const agentNode = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_languageModel: { required: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const openAiModel = createNodeType({
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
outputs: ['ai_languageModel'],
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([agentNode, openAiModel]);
|
||||
|
||||
// Exclude OpenAI model from results
|
||||
const relatedIds = engine.getRelatedSubnodeIds(
|
||||
['@n8n/n8n-nodes-langchain.agent'],
|
||||
new Set(['@n8n/n8n-nodes-langchain.lmChatOpenAi']),
|
||||
);
|
||||
|
||||
expect(relatedIds.has('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty set for nodes without builderHint.inputs', () => {
|
||||
const basicNode = createNodeType({
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
});
|
||||
const engine = new CodeBuilderNodeSearchEngine([basicNode]);
|
||||
|
||||
const relatedIds = engine.getRelatedSubnodeIds(['n8n-nodes-base.httpRequest'], new Set());
|
||||
|
||||
expect(relatedIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeType', () => {
|
||||
it('should return node type by ID', () => {
|
||||
const nodeType = searchEngine.getNodeType('n8n-nodes-base.code');
|
||||
|
||||
expect(nodeType).toBeDefined();
|
||||
expect(nodeType!.name).toBe('n8n-nodes-base.code');
|
||||
expect(nodeType!.displayName).toBe('Code');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent node', () => {
|
||||
const nodeType = searchEngine.getNodeType('nonexistent.node');
|
||||
|
||||
expect(nodeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Agent Iteration Handler
|
||||
*
|
||||
* Handles a single iteration of the agentic loop in the code builder agent.
|
||||
* Extracts the loop body logic for better testability and maintainability.
|
||||
*/
|
||||
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
|
||||
import type { StreamOutput, AgentMessageChunk } from '../../types/streaming';
|
||||
import { applySubgraphCacheMarkers } from '../../utils/cache-control/helpers';
|
||||
import type { TokenUsage } from '../types';
|
||||
import { extractTextContent, extractThinkingContent } from '../utils/content-extractors';
|
||||
|
||||
/** Type guard for response metadata with usage info */
|
||||
interface ResponseMetadataWithUsage {
|
||||
usage: { input_tokens?: number; output_tokens?: number; thinking_tokens?: number };
|
||||
}
|
||||
|
||||
function hasUsageMetadata(metadata: unknown): metadata is ResponseMetadataWithUsage {
|
||||
return (
|
||||
typeof metadata === 'object' &&
|
||||
metadata !== null &&
|
||||
'usage' in metadata &&
|
||||
typeof (metadata as ResponseMetadataWithUsage).usage === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for AgentIterationHandler
|
||||
*/
|
||||
export interface AgentIterationHandlerConfig {
|
||||
onTokenUsage?: (usage: TokenUsage) => void;
|
||||
/** Optional LangChain callbacks (e.g., LangSmith tracer) for LLM invocations */
|
||||
callbacks?: Callbacks;
|
||||
/** Optional metadata to include in LangSmith traces */
|
||||
runMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for a single iteration
|
||||
*/
|
||||
export interface IterationParams {
|
||||
/** LLM with tools bound */
|
||||
llmWithTools: Runnable<BaseMessage[], AIMessage, BaseChatModelCallOptions>;
|
||||
/** Current message history */
|
||||
messages: BaseMessage[];
|
||||
/** Optional abort signal */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Current iteration number */
|
||||
iteration: number;
|
||||
/** Per-iteration child callbacks (overrides constructor callbacks when provided) */
|
||||
callbacks?: Callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of LLM invocation (before tool processing)
|
||||
*/
|
||||
export interface LlmInvocationResult {
|
||||
/** The LLM response */
|
||||
response: AIMessage;
|
||||
/** Input tokens used */
|
||||
inputTokens: number;
|
||||
/** Output tokens used */
|
||||
outputTokens: number;
|
||||
/** Duration of LLM call in ms */
|
||||
llmDurationMs: number;
|
||||
/** Extracted text content (for streaming) */
|
||||
textContent: string | null;
|
||||
/** Extracted thinking content (for logging) */
|
||||
thinkingContent: string | null;
|
||||
/** Whether response has tool calls */
|
||||
hasToolCalls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the LLM invocation part of an iteration.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Invokes the LLM with message history
|
||||
* 2. Extracts token usage from response
|
||||
* 3. Extracts text and thinking content
|
||||
* 4. Yields streamed text content
|
||||
* 5. Adds AI message to history
|
||||
*
|
||||
* Tool call processing and final response parsing are handled separately
|
||||
* by the caller using the appropriate handlers.
|
||||
*/
|
||||
export class AgentIterationHandler {
|
||||
private onTokenUsage?: (usage: TokenUsage) => void;
|
||||
private callbacks?: Callbacks;
|
||||
private runMetadata?: Record<string, unknown>;
|
||||
|
||||
constructor(config: AgentIterationHandlerConfig = {}) {
|
||||
this.onTokenUsage = config.onTokenUsage;
|
||||
this.callbacks = config.callbacks;
|
||||
this.runMetadata = config.runMetadata;
|
||||
}
|
||||
|
||||
/** Get the configured callbacks (for creating parent runs) */
|
||||
getCallbacks(): Callbacks | undefined {
|
||||
return this.callbacks;
|
||||
}
|
||||
|
||||
/** Get the configured run metadata (for creating parent runs) */
|
||||
getRunMetadata(): Record<string, unknown> | undefined {
|
||||
return this.runMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the LLM and process the initial response.
|
||||
*
|
||||
* This handles the common LLM invocation logic:
|
||||
* - Invoke LLM with messages
|
||||
* - Extract token usage
|
||||
* - Extract text and thinking content
|
||||
* - Stream text content
|
||||
* - Add response to message history
|
||||
*
|
||||
* @param params - Iteration parameters
|
||||
* @yields StreamOutput chunks for streamed text content
|
||||
* @returns LLM invocation result for further processing
|
||||
*/
|
||||
async *invokeLlm(
|
||||
params: IterationParams,
|
||||
): AsyncGenerator<StreamOutput, LlmInvocationResult, unknown> {
|
||||
const { llmWithTools, messages, abortSignal, callbacks: iterationCallbacks } = params;
|
||||
|
||||
// Check for abort
|
||||
if (abortSignal?.aborted) {
|
||||
throw new Error('Aborted');
|
||||
}
|
||||
|
||||
// Apply cache markers for prompt caching optimization
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
const llmStartTime = Date.now();
|
||||
const response = await llmWithTools.invoke(messages, {
|
||||
signal: abortSignal,
|
||||
callbacks: iterationCallbacks ?? this.callbacks,
|
||||
metadata: this.runMetadata,
|
||||
});
|
||||
const llmDurationMs = Date.now() - llmStartTime;
|
||||
|
||||
// Extract token usage from response metadata using type guard
|
||||
const responseMetadata = response.response_metadata;
|
||||
const inputTokens = hasUsageMetadata(responseMetadata)
|
||||
? (responseMetadata.usage.input_tokens ?? 0)
|
||||
: 0;
|
||||
const outputTokens = hasUsageMetadata(responseMetadata)
|
||||
? (responseMetadata.usage.output_tokens ?? 0)
|
||||
: 0;
|
||||
const thinkingTokens = hasUsageMetadata(responseMetadata)
|
||||
? (responseMetadata.usage.thinking_tokens ?? 0)
|
||||
: 0;
|
||||
|
||||
// Report token usage via callback (fire and forget)
|
||||
if (this.onTokenUsage && (inputTokens > 0 || outputTokens > 0)) {
|
||||
this.onTokenUsage({ inputTokens, outputTokens, thinkingTokens });
|
||||
}
|
||||
|
||||
const thinkingContent = extractThinkingContent(response);
|
||||
|
||||
// Extract text content from response
|
||||
const textContent = extractTextContent(response);
|
||||
if (textContent) {
|
||||
const messageChunk: AgentMessageChunk = {
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: textContent,
|
||||
};
|
||||
yield {
|
||||
messages: [messageChunk],
|
||||
};
|
||||
}
|
||||
|
||||
// Add AI message to history
|
||||
messages.push(response);
|
||||
|
||||
return {
|
||||
response,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
llmDurationMs,
|
||||
textContent,
|
||||
thinkingContent,
|
||||
hasToolCalls: !!(response.tool_calls && response.tool_calls.length > 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Auto-Finalize Handler
|
||||
*
|
||||
* Handles the auto-finalize logic when the LLM stops calling tools in text editor mode.
|
||||
* Validates the code and either returns success or provides feedback for correction.
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { StreamOutput } from '../../types/streaming';
|
||||
import { FIX_VALIDATION_ERRORS_INSTRUCTION } from '../constants';
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
import type { ParseAndValidateResult } from '../types';
|
||||
import { pushValidationFeedback } from '../utils/content-extractors';
|
||||
import { formatWarnings } from '../utils/format-warnings';
|
||||
|
||||
/**
|
||||
* Parse and validate function type
|
||||
*/
|
||||
type ParseAndValidateFn = (
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
) => Promise<ParseAndValidateResult>;
|
||||
|
||||
/**
|
||||
* Get error context function type
|
||||
*/
|
||||
type GetErrorContextFn = (code: string, errorMessage: string) => string;
|
||||
|
||||
/**
|
||||
* Configuration for AutoFinalizeHandler
|
||||
*/
|
||||
export interface AutoFinalizeHandlerConfig {
|
||||
parseAndValidate: ParseAndValidateFn;
|
||||
getErrorContext: GetErrorContextFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for executing auto-finalize
|
||||
*/
|
||||
export interface AutoFinalizeParams {
|
||||
/** The current workflow code from text editor (null if no code yet) */
|
||||
code: string | null;
|
||||
/** The current workflow context for validation */
|
||||
currentWorkflow: WorkflowJSON | undefined;
|
||||
/** Message history to append feedback to */
|
||||
messages: BaseMessage[];
|
||||
/** Optional warning tracker for deduplicating repeated warnings */
|
||||
warningTracker?: WarningTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of auto-finalize execution
|
||||
*/
|
||||
export interface AutoFinalizeResult {
|
||||
/** Whether auto-finalize succeeded */
|
||||
success: boolean;
|
||||
/** Whether we prompted for code creation (no code existed) */
|
||||
promptedForCode?: boolean;
|
||||
/** The validated workflow (only on success) */
|
||||
workflow?: WorkflowJSON;
|
||||
/** Parse duration in milliseconds */
|
||||
parseDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the auto-finalize logic when the LLM stops calling tools.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Prompts for code creation if no code exists
|
||||
* 2. Validates the code and returns success on valid workflow
|
||||
* 3. Provides feedback for warnings or parse errors
|
||||
*/
|
||||
export class AutoFinalizeHandler {
|
||||
private parseAndValidate: ParseAndValidateFn;
|
||||
private getErrorContext: GetErrorContextFn;
|
||||
|
||||
constructor(config: AutoFinalizeHandlerConfig) {
|
||||
this.parseAndValidate = config.parseAndValidate;
|
||||
this.getErrorContext = config.getErrorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the auto-finalize logic.
|
||||
*
|
||||
* @param params - Execution parameters
|
||||
* @yields StreamOutput chunks (currently none, but kept for consistency)
|
||||
* @returns AutoFinalizeResult with success status and optional workflow
|
||||
*/
|
||||
// eslint-disable-next-line require-yield
|
||||
async *execute(
|
||||
params: AutoFinalizeParams,
|
||||
): AsyncGenerator<StreamOutput, AutoFinalizeResult, unknown> {
|
||||
const { code, currentWorkflow, messages, warningTracker } = params;
|
||||
|
||||
// No code yet - prompt to create
|
||||
if (!code) {
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
'Please use the text editor tool to create or edit the workflow code.',
|
||||
);
|
||||
return { success: false, promptedForCode: true };
|
||||
}
|
||||
|
||||
// Auto-validate and finalize
|
||||
const parseStartTime = Date.now();
|
||||
try {
|
||||
const result = await this.parseAndValidate(code, currentWorkflow);
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
|
||||
// Handle warnings
|
||||
if (result.warnings.length > 0) {
|
||||
const newWarnings = warningTracker
|
||||
? warningTracker.filterNewWarnings(result.warnings)
|
||||
: result.warnings;
|
||||
|
||||
if (newWarnings.length > 0) {
|
||||
if (warningTracker) {
|
||||
warningTracker.markAsSeen(newWarnings);
|
||||
}
|
||||
|
||||
const warningText = formatWarnings(newWarnings, warningTracker);
|
||||
const errorContext = this.getErrorContext(code, newWarnings[0].message);
|
||||
|
||||
// Send only new warnings back to agent for correction
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
`Validation warnings:\n${warningText}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
);
|
||||
|
||||
return { success: false, parseDuration };
|
||||
}
|
||||
}
|
||||
|
||||
// Success - workflow validated
|
||||
return {
|
||||
success: true,
|
||||
workflow: result.workflow,
|
||||
parseDuration,
|
||||
};
|
||||
} catch (error) {
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorContext = this.getErrorContext(code, errorMessage);
|
||||
|
||||
// Send error back to agent for correction
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
`Parse error: ${errorMessage}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
);
|
||||
|
||||
return { success: false, parseDuration };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Chat Setup Handler
|
||||
*
|
||||
* Handles the setup phase of the chat() method in CodeBuilderAgent.
|
||||
* Extracts initialization logic to reduce cyclomatic complexity in chat().
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseChatModel,
|
||||
BaseChatModelCallOptions,
|
||||
} from '@langchain/core/language_models/chat_models';
|
||||
import type { AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { generateWorkflowCode } from '@n8n/workflow-sdk';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { ChatPayload } from '../../workflow-builder-agent';
|
||||
import { TEXT_EDITOR_TOOL, VALIDATE_TOOL, BATCH_STR_REPLACE_TOOL } from '../constants';
|
||||
import { buildCodeBuilderPrompt, type HistoryContext } from '../prompts';
|
||||
import { TextEditorHandler } from './text-editor-handler';
|
||||
import { TextEditorToolHandler } from './text-editor-tool-handler';
|
||||
import type { TextEditorCommand } from './text-editor.types';
|
||||
import type { ParseAndValidateResult } from '../types';
|
||||
import { SDK_IMPORT_STATEMENT } from '../utils/extract-code';
|
||||
|
||||
/**
|
||||
* Parse and validate function type
|
||||
*/
|
||||
type ParseAndValidateFn = (
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
) => Promise<ParseAndValidateResult>;
|
||||
|
||||
/**
|
||||
* Get error context function type
|
||||
*/
|
||||
type GetErrorContextFn = (code: string, errorMessage: string) => string;
|
||||
|
||||
/**
|
||||
* Configuration for ChatSetupHandler
|
||||
*/
|
||||
export interface ChatSetupHandlerConfig {
|
||||
llm: BaseChatModel;
|
||||
tools: StructuredToolInterface[];
|
||||
enableTextEditorConfig?: boolean;
|
||||
parseAndValidate: ParseAndValidateFn;
|
||||
getErrorContext: GetErrorContextFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for setup execution
|
||||
*/
|
||||
export interface ChatSetupParams {
|
||||
payload: ChatPayload;
|
||||
historyContext?: HistoryContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for LLM with tools bound - matches what AgentIterationHandler expects
|
||||
*/
|
||||
export type LlmWithTools = Runnable<BaseMessage[], AIMessage, BaseChatModelCallOptions>;
|
||||
|
||||
/**
|
||||
* Result of chat setup
|
||||
*/
|
||||
export interface ChatSetupResult {
|
||||
llmWithTools: LlmWithTools;
|
||||
messages: BaseMessage[];
|
||||
textEditorEnabled: boolean;
|
||||
preGeneratedWorkflowCode?: string;
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the setup phase of the chat() method.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Determines if text editor should be enabled
|
||||
* 2. Pre-generates workflow code for consistency
|
||||
* 3. Builds the prompt with workflow context
|
||||
* 4. Binds tools to the LLM
|
||||
* 5. Formats initial messages
|
||||
* 6. Initializes text editor handlers if enabled
|
||||
*/
|
||||
export class ChatSetupHandler {
|
||||
private llm: BaseChatModel;
|
||||
private tools: StructuredToolInterface[];
|
||||
private enableTextEditorConfig?: boolean;
|
||||
private parseAndValidate: ParseAndValidateFn;
|
||||
private getErrorContext: GetErrorContextFn;
|
||||
|
||||
constructor(config: ChatSetupHandlerConfig) {
|
||||
this.llm = config.llm;
|
||||
this.tools = config.tools;
|
||||
this.enableTextEditorConfig = config.enableTextEditorConfig;
|
||||
this.parseAndValidate = config.parseAndValidate;
|
||||
this.getErrorContext = config.getErrorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the setup phase of chat().
|
||||
*
|
||||
* @param params - Setup parameters
|
||||
* @returns ChatSetupResult with all initialized components
|
||||
*/
|
||||
async execute(params: ChatSetupParams): Promise<ChatSetupResult> {
|
||||
const { payload, historyContext } = params;
|
||||
const currentWorkflow = payload.workflowContext?.currentWorkflow as WorkflowJSON | undefined;
|
||||
|
||||
// Treat empty workflows (no nodes) as no workflow for code context.
|
||||
// This forces the agent to use `create` instead of editing pre-populated code.
|
||||
const workflowForCodeContext =
|
||||
(currentWorkflow?.nodes?.length ?? 0) > 0 ? currentWorkflow : undefined;
|
||||
|
||||
// Pre-generate workflow code for consistency between prompt and text editor
|
||||
const preGeneratedWorkflowCode = this.preGenerateWorkflowCode(payload, workflowForCodeContext);
|
||||
|
||||
// Check if text editor mode should be enabled
|
||||
const textEditorEnabled = this.shouldEnableTextEditor();
|
||||
|
||||
// Build prompt
|
||||
const prompt = buildCodeBuilderPrompt(workflowForCodeContext, historyContext, {
|
||||
enableTextEditor: textEditorEnabled,
|
||||
executionSchema: payload.workflowContext?.executionSchema,
|
||||
executionData: payload.workflowContext?.executionData,
|
||||
expressionValues: payload.workflowContext?.expressionValues,
|
||||
preGeneratedCode: preGeneratedWorkflowCode,
|
||||
valuesExcluded: payload.workflowContext?.valuesExcluded,
|
||||
pinnedNodes: payload.workflowContext?.pinnedNodes,
|
||||
planOutput: payload.planOutput,
|
||||
});
|
||||
|
||||
// Bind tools to LLM
|
||||
const llmWithTools = this.bindToolsToLlm(textEditorEnabled);
|
||||
|
||||
// Format initial messages
|
||||
const messages = await this.formatInitialMessages(prompt, payload.message);
|
||||
|
||||
// Initialize text editor handlers if enabled
|
||||
const { textEditorHandler, textEditorToolHandler } = this.initializeTextEditorHandlers(
|
||||
textEditorEnabled,
|
||||
preGeneratedWorkflowCode,
|
||||
);
|
||||
|
||||
return {
|
||||
llmWithTools,
|
||||
messages,
|
||||
textEditorEnabled,
|
||||
preGeneratedWorkflowCode,
|
||||
currentWorkflow,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to enable the text editor tool.
|
||||
* Auto-enables for Claude 4.x models if not explicitly configured.
|
||||
*/
|
||||
private shouldEnableTextEditor(): boolean {
|
||||
if (this.enableTextEditorConfig !== undefined) {
|
||||
return this.enableTextEditorConfig;
|
||||
}
|
||||
// Auto-enable for Claude 4.x models (check model name from LLM)
|
||||
const modelName = (this.llm as { modelId?: string }).modelId ?? '';
|
||||
return (
|
||||
modelName.includes('claude-4') ||
|
||||
modelName.includes('opus-4') ||
|
||||
modelName.includes('sonnet-4')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-generate workflow code with execution context
|
||||
*/
|
||||
private preGenerateWorkflowCode(
|
||||
payload: ChatPayload,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
): string | undefined {
|
||||
if (!currentWorkflow) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return generateWorkflowCode({
|
||||
workflow: currentWorkflow,
|
||||
executionSchema: payload.workflowContext?.executionSchema,
|
||||
executionData: payload.workflowContext?.executionData,
|
||||
expressionValues: payload.workflowContext?.expressionValues,
|
||||
valuesExcluded: payload.workflowContext?.valuesExcluded,
|
||||
pinnedNodes: payload.workflowContext?.pinnedNodes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind tools to LLM
|
||||
*
|
||||
* @returns LLM with tools bound, typed for use with AgentIterationHandler
|
||||
*/
|
||||
private bindToolsToLlm(textEditorEnabled: boolean): LlmWithTools {
|
||||
if (!this.llm.bindTools) {
|
||||
throw new Error('LLM does not support bindTools - cannot use tools for node discovery');
|
||||
}
|
||||
|
||||
const toolsToUse = textEditorEnabled
|
||||
? [...this.tools, TEXT_EDITOR_TOOL, VALIDATE_TOOL, BATCH_STR_REPLACE_TOOL]
|
||||
: this.tools;
|
||||
|
||||
return this.llm.bindTools(toolsToUse) as LlmWithTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format initial messages from the prompt
|
||||
*/
|
||||
private async formatInitialMessages(
|
||||
prompt: ReturnType<typeof buildCodeBuilderPrompt>,
|
||||
userMessage: string,
|
||||
): Promise<BaseMessage[]> {
|
||||
const formattedMessages = await prompt.formatMessages({ userMessage });
|
||||
return [...formattedMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize text editor handlers if enabled
|
||||
*/
|
||||
private initializeTextEditorHandlers(
|
||||
textEditorEnabled: boolean,
|
||||
preGeneratedWorkflowCode?: string,
|
||||
): {
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
} {
|
||||
if (!textEditorEnabled) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const textEditorHandler = new TextEditorHandler();
|
||||
|
||||
// Create text editor tool handler (wraps the text editor handler)
|
||||
const textEditorToolHandler = new TextEditorToolHandler({
|
||||
textEditorExecute: (args) => textEditorHandler.execute(args as unknown as TextEditorCommand),
|
||||
textEditorGetCode: () => textEditorHandler.getWorkflowCode(),
|
||||
parseAndValidate: this.parseAndValidate,
|
||||
getErrorContext: this.getErrorContext,
|
||||
});
|
||||
|
||||
// Pre-populate with the SAME code that's in the system prompt
|
||||
// This ensures str_replace commands match what the LLM sees
|
||||
if (preGeneratedWorkflowCode) {
|
||||
const codeWithImport = `${SDK_IMPORT_STATEMENT}\n\n${preGeneratedWorkflowCode}`;
|
||||
textEditorHandler.setWorkflowCode(codeWithImport);
|
||||
}
|
||||
|
||||
return { textEditorHandler, textEditorToolHandler };
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Final Response Handler
|
||||
*
|
||||
* Handles parsing and validating the final response when the LLM stops making tool calls.
|
||||
* Extracts workflow code from the response and validates it, providing feedback for corrections.
|
||||
*/
|
||||
|
||||
import type { AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
import type { ParseAndValidateResult, WorkflowCodeOutput } from '../types';
|
||||
import { extractTextContent, pushValidationFeedback } from '../utils/content-extractors';
|
||||
import { extractWorkflowCode } from '../utils/extract-code';
|
||||
import { formatWarnings } from '../utils/format-warnings';
|
||||
|
||||
/**
|
||||
* Parse and validate function type
|
||||
*/
|
||||
type ParseAndValidateFn = (
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
) => Promise<ParseAndValidateResult>;
|
||||
|
||||
/**
|
||||
* Configuration for FinalResponseHandler
|
||||
*/
|
||||
export interface FinalResponseHandlerConfig {
|
||||
parseAndValidate: ParseAndValidateFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for processing the final response
|
||||
*/
|
||||
export interface FinalResponseParams {
|
||||
/** The LLM response to parse */
|
||||
response: AIMessage;
|
||||
/** Current workflow context for validation */
|
||||
currentWorkflow: WorkflowJSON | undefined;
|
||||
/** Message history to append feedback to */
|
||||
messages: BaseMessage[];
|
||||
/** Warning tracker for deduplication */
|
||||
warningTracker: WarningTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of final response processing
|
||||
*/
|
||||
export interface FinalResponseResult {
|
||||
/** Whether processing succeeded (workflow is ready) */
|
||||
success: boolean;
|
||||
/** The validated workflow (only on success) */
|
||||
workflow?: WorkflowJSON;
|
||||
/** The source code (only on success) */
|
||||
sourceCode?: string;
|
||||
/** Parse duration in milliseconds */
|
||||
parseDuration?: number;
|
||||
/** Whether this was a parse error (should increment consecutiveParseErrors) */
|
||||
isParseError?: boolean;
|
||||
/** Whether the loop should continue (for warnings/errors that got feedback) */
|
||||
shouldContinue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the final response when the LLM stops making tool calls.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Parses structured output (TypeScript code blocks) from the response
|
||||
* 2. Validates the workflow code
|
||||
* 3. Handles warnings by providing feedback to the agent
|
||||
* 4. Returns whether the workflow is ready or needs correction
|
||||
*/
|
||||
export class FinalResponseHandler {
|
||||
private parseAndValidate: ParseAndValidateFn;
|
||||
|
||||
constructor(config: FinalResponseHandlerConfig) {
|
||||
this.parseAndValidate = config.parseAndValidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the final response from the LLM.
|
||||
*
|
||||
* @param params - Processing parameters
|
||||
* @returns FinalResponseResult with success status and optional workflow
|
||||
*/
|
||||
async process(params: FinalResponseParams): Promise<FinalResponseResult> {
|
||||
const { response, currentWorkflow, messages, warningTracker } = params;
|
||||
|
||||
// Parse structured output from response
|
||||
const parseResult = this.parseStructuredOutput(response);
|
||||
|
||||
if (!parseResult.result) {
|
||||
// Add follow-up message with error
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
`Could not parse your response: ${parseResult.error}\n\nPlease provide your workflow code in a \`\`\`typescript code block.`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
isParseError: true,
|
||||
shouldContinue: true,
|
||||
};
|
||||
}
|
||||
|
||||
const workflowCode = parseResult.result.workflowCode;
|
||||
|
||||
// Try to parse and validate the workflow code
|
||||
const parseStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.parseAndValidate(workflowCode, currentWorkflow);
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
|
||||
// Check for new warnings
|
||||
const newWarnings = warningTracker.filterNewWarnings(result.warnings);
|
||||
|
||||
if (newWarnings.length > 0) {
|
||||
// Mark warnings as seen
|
||||
warningTracker.markAsSeen(newWarnings);
|
||||
|
||||
// Format warnings
|
||||
const warningMessages = formatWarnings(newWarnings.slice(0, 5), warningTracker);
|
||||
|
||||
// Send feedback to agent
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
`The workflow code has validation warnings that should be addressed:\n\n${warningMessages}\n\nPlease fix these issues and provide the corrected version in a \`\`\`typescript code block.`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
parseDuration,
|
||||
shouldContinue: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workflow: result.workflow,
|
||||
sourceCode: workflowCode,
|
||||
parseDuration,
|
||||
shouldContinue: false,
|
||||
};
|
||||
} catch (parseError) {
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
||||
|
||||
// Send feedback to agent
|
||||
pushValidationFeedback(
|
||||
messages,
|
||||
`The workflow code you generated has a parsing error:\n\n${errorMessage}\n\nPlease fix the code and provide the corrected version in a \`\`\`typescript code block.`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
parseDuration,
|
||||
isParseError: true,
|
||||
shouldContinue: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse structured output from an AI message.
|
||||
* Extracts workflow code from TypeScript code blocks.
|
||||
*/
|
||||
private parseStructuredOutput(message: AIMessage): {
|
||||
result: WorkflowCodeOutput | null;
|
||||
error: string | null;
|
||||
} {
|
||||
const content = extractTextContent(message);
|
||||
if (!content) {
|
||||
return { result: null, error: 'No text content found in response' };
|
||||
}
|
||||
|
||||
// Extract code from TypeScript code blocks
|
||||
const workflowCode = extractWorkflowCode(content);
|
||||
|
||||
// Check if we got valid code
|
||||
if (!workflowCode?.includes('workflow')) {
|
||||
return {
|
||||
result: null,
|
||||
error:
|
||||
'No valid workflow code found in response. Please provide your code in a ```typescript code block.',
|
||||
};
|
||||
}
|
||||
|
||||
return { result: { workflowCode }, error: null };
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Parse and Validate Handler
|
||||
*
|
||||
* Handles parsing TypeScript workflow code to WorkflowJSON and validation.
|
||||
* Consolidates duplicate parse/validate logic that was previously in multiple
|
||||
* places in the code builder agent.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { parseWorkflowCodeToBuilder, validateWorkflow, workflow } from '@n8n/workflow-sdk';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { ParseAndValidateResult, ValidationWarning } from '../types';
|
||||
import { stripImportStatements } from '../utils/extract-code';
|
||||
|
||||
/**
|
||||
* Configuration for ParseValidateHandler
|
||||
*/
|
||||
export interface ParseValidateHandlerConfig {
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles parsing and validation of workflow code.
|
||||
*
|
||||
* Consolidates the parse/validate logic that was duplicated in:
|
||||
* - Main loop (lines 784-846)
|
||||
* - Text editor auto-finalize (lines 695-765)
|
||||
* - Validate tool (lines 1428-1594)
|
||||
*/
|
||||
/** Validation issue from graph or JSON validation */
|
||||
interface ValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export class ParseValidateHandler {
|
||||
private logger?: Logger;
|
||||
|
||||
constructor(config: ParseValidateHandlerConfig = {}) {
|
||||
this.logger = config.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect validation issues (errors or warnings) into the warnings array.
|
||||
* Used to normalize all validation feedback for agent self-correction.
|
||||
*/
|
||||
private collectValidationIssues(
|
||||
issues: ValidationIssue[],
|
||||
allWarnings: ValidationWarning[],
|
||||
context: string,
|
||||
logLevel: 'warn' | 'info',
|
||||
): void {
|
||||
if (issues.length === 0) return;
|
||||
|
||||
if (logLevel === 'warn') {
|
||||
this.logger?.warn(`Graph validation ${context.toLowerCase()}`, {
|
||||
[context.toLowerCase()]: issues.map((i) => i.message),
|
||||
});
|
||||
} else {
|
||||
this.logger?.info(`Graph validation ${context.toLowerCase()}`, {
|
||||
[context.toLowerCase()]: issues.map((i) => i.message),
|
||||
});
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
allWarnings.push({
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
nodeName: issue.nodeName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an existing workflow (from JSON) using graph validation only.
|
||||
*
|
||||
* Used to discover pre-existing warnings before the agent starts editing,
|
||||
* so those warnings can be annotated as [pre-existing] when shown to the agent.
|
||||
*
|
||||
* Skips JSON validation since we already have the JSON — graph validation
|
||||
* alone is sufficient for discovering pre-existing warnings.
|
||||
*
|
||||
* @param json - The existing workflow JSON to validate
|
||||
* @returns Array of validation warnings found in the existing workflow
|
||||
*/
|
||||
validateExistingWorkflow(json: WorkflowJSON): ValidationWarning[] {
|
||||
if (json.nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const builder = workflow.fromJSON(json);
|
||||
const allWarnings: ValidationWarning[] = [];
|
||||
const graphValidation = builder.validate();
|
||||
this.collectValidationIssues(
|
||||
graphValidation.errors,
|
||||
allWarnings,
|
||||
'GRAPH VALIDATION ERRORS',
|
||||
'warn',
|
||||
);
|
||||
this.collectValidationIssues(
|
||||
graphValidation.warnings,
|
||||
allWarnings,
|
||||
'GRAPH VALIDATION WARNINGS',
|
||||
'info',
|
||||
);
|
||||
return allWarnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse TypeScript code to WorkflowJSON and validate.
|
||||
*
|
||||
* @param code - The TypeScript workflow code to parse
|
||||
* @param currentWorkflow - Optional current workflow for context (used for pin data generation)
|
||||
* @returns ParseAndValidateResult with workflow and any warnings
|
||||
* @throws Error if parsing fails or there are validation errors
|
||||
*/
|
||||
async parseAndValidate(
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
): Promise<ParseAndValidateResult> {
|
||||
// Strip import statements before parsing - SDK functions are available as globals
|
||||
const codeToParse = stripImportStatements(code);
|
||||
|
||||
try {
|
||||
// Parse the TypeScript code to WorkflowBuilder
|
||||
this.logger?.debug('Parsing WorkflowCode', { codeLength: codeToParse.length });
|
||||
const builder = parseWorkflowCodeToBuilder(codeToParse);
|
||||
|
||||
// Regenerate node IDs deterministically to ensure stable IDs across re-parses
|
||||
builder.regenerateNodeIds();
|
||||
|
||||
// Run graph + JSON validation
|
||||
const allWarnings: ValidationWarning[] = [];
|
||||
|
||||
// Validate the graph structure BEFORE converting to JSON
|
||||
const graphValidation = builder.validate();
|
||||
|
||||
// Collect graph validation errors as warnings for agent self-correction
|
||||
this.collectValidationIssues(
|
||||
graphValidation.errors,
|
||||
allWarnings,
|
||||
'GRAPH VALIDATION ERRORS',
|
||||
'warn',
|
||||
);
|
||||
|
||||
// Collect graph validation warnings
|
||||
this.collectValidationIssues(
|
||||
graphValidation.warnings,
|
||||
allWarnings,
|
||||
'GRAPH VALIDATION WARNINGS',
|
||||
'info',
|
||||
);
|
||||
|
||||
// Convert to JSON for JSON-based validation
|
||||
const json = builder.toJSON();
|
||||
|
||||
// Run JSON-based validation for additional checks
|
||||
const validationResult = validateWorkflow(json);
|
||||
|
||||
// Collect JSON validation errors as warnings for agent self-correction
|
||||
this.collectValidationIssues(
|
||||
validationResult.errors,
|
||||
allWarnings,
|
||||
'JSON VALIDATION ERRORS',
|
||||
'warn',
|
||||
);
|
||||
|
||||
// Collect JSON validation warnings
|
||||
this.collectValidationIssues(
|
||||
validationResult.warnings,
|
||||
allWarnings,
|
||||
'JSON VALIDATION WARNINGS',
|
||||
'info',
|
||||
);
|
||||
|
||||
// Generate pin data for new nodes only (nodes not in currentWorkflow)
|
||||
builder.generatePinData({ beforeWorkflow: currentWorkflow });
|
||||
|
||||
// Convert to JSON
|
||||
const workflowJson: WorkflowJSON = builder.toJSON();
|
||||
|
||||
this.logger?.debug('Parsed workflow', {
|
||||
id: workflowJson.id,
|
||||
name: workflowJson.name,
|
||||
nodeCount: workflowJson.nodes.length,
|
||||
});
|
||||
|
||||
// Return both workflow and warnings for agent self-correction
|
||||
return { workflow: workflowJson, warnings: allWarnings };
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to parse WorkflowCode', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
code: code.substring(0, 500),
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse generated workflow code: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error context with line numbers for debugging.
|
||||
*
|
||||
* @param code - The code to extract context from
|
||||
* @param errorMessage - The error message (may contain line number)
|
||||
* @returns Formatted code context around the error
|
||||
*/
|
||||
getErrorContext(code: string, errorMessage: string): string {
|
||||
// Try to extract line number from error message (e.g., "at line 5" or "Line 5:")
|
||||
const lineMatch = errorMessage.match(/(?:line|Line)\s*(\d+)/i);
|
||||
if (!lineMatch) {
|
||||
// No line number - show first 10 lines as context
|
||||
const lines = code.split('\n').slice(0, 10);
|
||||
return `Code context:\n${lines.map((l, i) => `${i + 1}: ${l}`).join('\n')}`;
|
||||
}
|
||||
|
||||
const errorLine = parseInt(lineMatch[1], 10);
|
||||
const lines = code.split('\n');
|
||||
|
||||
// Show 3 lines before and after the error line
|
||||
const start = Math.max(0, errorLine - 4);
|
||||
const end = Math.min(lines.length, errorLine + 3);
|
||||
const context = lines
|
||||
.slice(start, end)
|
||||
.map((l, i) => {
|
||||
const lineNum = start + i + 1;
|
||||
const marker = lineNum === errorLine ? '> ' : ' ';
|
||||
return `${marker}${lineNum}: ${l}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const contextText = `Code around line ${errorLine}:\n${context}`;
|
||||
|
||||
const hint = this.detectFixHint(lines, errorLine);
|
||||
if (hint) {
|
||||
return `${contextText}\n\n${hint}`;
|
||||
}
|
||||
|
||||
return contextText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect known error patterns near the error line and return a fix hint.
|
||||
*
|
||||
* Checks for `${{` inside backtick template literals — a common issue where
|
||||
* JS interprets `${{` as template literal interpolation `${...}`.
|
||||
*/
|
||||
private detectFixHint(lines: string[], errorLine: number): string | null {
|
||||
const searchStart = Math.max(0, errorLine - 5);
|
||||
const searchEnd = Math.min(lines.length, errorLine + 5);
|
||||
const nearbyCode = lines.slice(searchStart, searchEnd).join('\n');
|
||||
|
||||
// Detect ${{ inside backtick template literals (expr(`...${{...}}`))
|
||||
if (/expr\s*\(\s*`[^`]*\$\{\{/.test(nearbyCode)) {
|
||||
return (
|
||||
"HINT: The '$' before '{{' inside a backtick template literal is interpreted as JS template interpolation. " +
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
"Use single quotes instead of backticks for expr(), e.g. expr('Amount: ${{ $json.amount }}'). " +
|
||||
'Fix ALL occurrences in the file at once.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Session Chat Handler
|
||||
*
|
||||
* Wraps agent chat with session management. Handles loading, compacting,
|
||||
* and saving sessions, as well as filtering internal messages.
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
|
||||
import type { SessionMessagesChunk, StreamChunk, StreamOutput } from '../../types/streaming';
|
||||
import type { ChatPayload } from '../../workflow-builder-agent';
|
||||
import type { HistoryContext } from '../prompts';
|
||||
import {
|
||||
loadCodeBuilderSession,
|
||||
saveCodeBuilderSession,
|
||||
compactSessionIfNeeded,
|
||||
generateCodeBuilderThreadId,
|
||||
saveSessionMessages,
|
||||
} from '../utils/code-builder-session';
|
||||
|
||||
/** Type guard for SessionMessagesChunk */
|
||||
function isSessionMessagesChunk(chunk: StreamChunk): chunk is SessionMessagesChunk {
|
||||
return chunk.type === 'session-messages' && 'messages' in chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent chat function type
|
||||
*/
|
||||
type AgentChatFn = (
|
||||
payload: ChatPayload,
|
||||
userId: string,
|
||||
abortSignal?: AbortSignal,
|
||||
historyContext?: HistoryContext,
|
||||
) => AsyncGenerator<StreamOutput, void, unknown>;
|
||||
|
||||
/**
|
||||
* Configuration for SessionChatHandler
|
||||
*/
|
||||
export interface SessionChatHandlerConfig {
|
||||
checkpointer: MemorySaver;
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Callback when generation completes successfully (not aborted).
|
||||
* Used for credit deduction and UI updates.
|
||||
*/
|
||||
onGenerationSuccess?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for executing session-wrapped chat
|
||||
*/
|
||||
export interface SessionChatParams {
|
||||
payload: ChatPayload;
|
||||
userId: string;
|
||||
abortSignal?: AbortSignal;
|
||||
agentChat: AgentChatFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles session management for agent chat.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Loads existing session from checkpointer
|
||||
* 2. Compacts session if needed (when messages exceed threshold)
|
||||
* 3. Builds history context for the agent
|
||||
* 4. Delegates to the agent with history context
|
||||
* 5. Filters internal messages (session-messages) from output
|
||||
* 6. Saves session after successful generation
|
||||
*/
|
||||
export class SessionChatHandler {
|
||||
private checkpointer: MemorySaver;
|
||||
private llm: BaseChatModel;
|
||||
private logger?: Logger;
|
||||
private onGenerationSuccess?: () => Promise<void>;
|
||||
|
||||
constructor(config: SessionChatHandlerConfig) {
|
||||
this.checkpointer = config.checkpointer;
|
||||
this.llm = config.llm;
|
||||
this.logger = config.logger;
|
||||
this.onGenerationSuccess = config.onGenerationSuccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build history context from session if available.
|
||||
*/
|
||||
private buildHistoryContext(session: {
|
||||
userMessages: string[];
|
||||
previousSummary?: string;
|
||||
}): HistoryContext | undefined {
|
||||
if (session.userMessages.length > 0 || session.previousSummary) {
|
||||
return {
|
||||
userMessages: session.userMessages,
|
||||
previousSummary: session.previousSummary,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a chunk from the agent, tracking generation success and session messages.
|
||||
*/
|
||||
private processChunk(chunk: StreamOutput): {
|
||||
generationSucceeded: boolean;
|
||||
sessionMessages: unknown[] | undefined;
|
||||
filteredMessages: StreamOutput['messages'];
|
||||
} {
|
||||
let generationSucceeded = false;
|
||||
let sessionMessages: unknown[] | undefined;
|
||||
|
||||
if (chunk.messages?.some((msg) => msg.type === 'workflow-updated')) {
|
||||
generationSucceeded = true;
|
||||
}
|
||||
|
||||
for (const msg of chunk.messages ?? []) {
|
||||
if (isSessionMessagesChunk(msg)) {
|
||||
sessionMessages = msg.messages;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMessages = chunk.messages?.filter((msg) => msg.type !== 'session-messages');
|
||||
|
||||
return { generationSucceeded, sessionMessages, filteredMessages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute session-wrapped chat.
|
||||
*
|
||||
* @param params - Chat parameters including agent function
|
||||
* @yields StreamOutput chunks with internal messages filtered
|
||||
*/
|
||||
async *execute(params: SessionChatParams): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
const { payload, userId, abortSignal, agentChat } = params;
|
||||
|
||||
// Extract workflow ID from context
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
|
||||
if (!workflowId) {
|
||||
// No workflow ID - cannot manage session, delegate directly
|
||||
this.logger?.debug('No workflow ID, skipping session management');
|
||||
yield* agentChat(payload, userId, abortSignal);
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = generateCodeBuilderThreadId(workflowId, userId);
|
||||
|
||||
// Load existing session
|
||||
let session = await loadCodeBuilderSession(this.checkpointer, threadId);
|
||||
|
||||
this.logger?.debug('Loaded CodeBuilder session', {
|
||||
threadId,
|
||||
userMessagesCount: session.userMessages.length,
|
||||
hasPreviousSummary: !!session.previousSummary,
|
||||
});
|
||||
|
||||
// Compact if needed
|
||||
session = await compactSessionIfNeeded(session, this.llm);
|
||||
const historyContext = this.buildHistoryContext(session);
|
||||
|
||||
// Track generation success and capture session messages
|
||||
let generationSucceeded = false;
|
||||
let sessionMessages: unknown[] | undefined;
|
||||
|
||||
// Delegate to agent with history context
|
||||
for await (const chunk of agentChat(payload, userId, abortSignal, historyContext)) {
|
||||
const result = this.processChunk(chunk);
|
||||
if (result.generationSucceeded) generationSucceeded = true;
|
||||
if (result.sessionMessages) sessionMessages = result.sessionMessages;
|
||||
|
||||
if (result.filteredMessages && result.filteredMessages.length > 0) {
|
||||
yield { messages: result.filteredMessages };
|
||||
}
|
||||
}
|
||||
|
||||
// Save current message to session
|
||||
session.userMessages.push(payload.message);
|
||||
await saveCodeBuilderSession(this.checkpointer, threadId, session);
|
||||
|
||||
// Save full message history for frontend retrieval
|
||||
if (generationSucceeded && sessionMessages) {
|
||||
await saveSessionMessages(
|
||||
this.checkpointer,
|
||||
workflowId,
|
||||
userId,
|
||||
sessionMessages,
|
||||
payload.versionId,
|
||||
payload.id,
|
||||
);
|
||||
|
||||
this.logger?.debug('Saved session messages to SessionManager thread', {
|
||||
workflowId,
|
||||
userId,
|
||||
messageCount: sessionMessages.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (generationSucceeded && this.onGenerationSuccess) {
|
||||
try {
|
||||
await this.onGenerationSuccess();
|
||||
} catch (error) {
|
||||
this.logger?.warn('Failed to execute onGenerationSuccess callback', { error });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger?.debug('Saved CodeBuilder session', {
|
||||
threadId,
|
||||
newMessageCount: session.userMessages.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Tests for AgentIterationHandler
|
||||
*/
|
||||
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
|
||||
import { AgentIterationHandler } from '../agent-iteration-handler';
|
||||
|
||||
describe('AgentIterationHandler', () => {
|
||||
let handler: AgentIterationHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('invokeLlm', () => {
|
||||
describe('onTokenUsage callback', () => {
|
||||
it('should call onTokenUsage callback with token counts when tokens are used', async () => {
|
||||
const onTokenUsage = jest.fn();
|
||||
|
||||
handler = new AgentIterationHandler({
|
||||
onTokenUsage,
|
||||
});
|
||||
|
||||
// Create mock LLM that returns a response with token usage
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
// Consume the generator to completion
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
});
|
||||
|
||||
// Exhaust the generator
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
expect(onTokenUsage).toHaveBeenCalledTimes(1);
|
||||
expect(onTokenUsage).toHaveBeenCalledWith({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
thinkingTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onTokenUsage when tokens are zero', async () => {
|
||||
const onTokenUsage = jest.fn();
|
||||
|
||||
handler = new AgentIterationHandler({
|
||||
onTokenUsage,
|
||||
});
|
||||
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
});
|
||||
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
expect(onTokenUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onTokenUsage when no usage metadata present', async () => {
|
||||
const onTokenUsage = jest.fn();
|
||||
|
||||
handler = new AgentIterationHandler({
|
||||
onTokenUsage,
|
||||
});
|
||||
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
});
|
||||
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
expect(onTokenUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without onTokenUsage callback', async () => {
|
||||
handler = new AgentIterationHandler({});
|
||||
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
// The generator should complete without error
|
||||
expect(mockLlmWithTools.invoke).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-iteration callbacks override', () => {
|
||||
it('should use per-iteration callbacks when provided instead of constructor callbacks', async () => {
|
||||
const constructorCallbacks = [{ handleLLMStart: jest.fn() }];
|
||||
const iterationCallbacks = [{ handleLLMStart: jest.fn() }];
|
||||
|
||||
handler = new AgentIterationHandler({
|
||||
callbacks: constructorCallbacks,
|
||||
});
|
||||
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
callbacks: iterationCallbacks,
|
||||
});
|
||||
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
// Verify the LLM was invoked with the per-iteration callbacks, not the constructor ones
|
||||
expect(mockLlmWithTools.invoke).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ callbacks: iterationCallbacks }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to constructor callbacks when per-iteration callbacks are not provided', async () => {
|
||||
const constructorCallbacks = [{ handleLLMStart: jest.fn() }];
|
||||
|
||||
handler = new AgentIterationHandler({
|
||||
callbacks: constructorCallbacks,
|
||||
});
|
||||
|
||||
const mockResponse = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {},
|
||||
});
|
||||
|
||||
const mockLlmWithTools = {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
};
|
||||
|
||||
const messages = [new HumanMessage('Test')];
|
||||
|
||||
const generator = handler.invokeLlm({
|
||||
llmWithTools: mockLlmWithTools as never,
|
||||
messages,
|
||||
iteration: 1,
|
||||
});
|
||||
|
||||
for await (const _chunk of generator) {
|
||||
// consume chunks
|
||||
}
|
||||
|
||||
// Verify the LLM was invoked with the constructor callbacks
|
||||
expect(mockLlmWithTools.invoke).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ callbacks: constructorCallbacks }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCallbacks', () => {
|
||||
it('should return the configured callbacks', () => {
|
||||
const callbacks = [{ handleLLMStart: jest.fn() }];
|
||||
handler = new AgentIterationHandler({
|
||||
callbacks,
|
||||
});
|
||||
|
||||
expect(handler.getCallbacks()).toBe(callbacks);
|
||||
});
|
||||
|
||||
it('should return undefined when no callbacks configured', () => {
|
||||
handler = new AgentIterationHandler({});
|
||||
|
||||
expect(handler.getCallbacks()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunMetadata', () => {
|
||||
it('should return the configured run metadata', () => {
|
||||
const runMetadata = { sessionId: 'test-123' };
|
||||
handler = new AgentIterationHandler({
|
||||
runMetadata,
|
||||
});
|
||||
|
||||
expect(handler.getRunMetadata()).toBe(runMetadata);
|
||||
});
|
||||
|
||||
it('should return undefined when no run metadata configured', () => {
|
||||
handler = new AgentIterationHandler({});
|
||||
|
||||
expect(handler.getRunMetadata()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Tests for Auto-Finalize Handler
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import { WarningTracker } from '../../state/warning-tracker';
|
||||
import type { ParseAndValidateResult } from '../../types';
|
||||
import { AutoFinalizeHandler } from '../auto-finalize-handler';
|
||||
|
||||
describe('AutoFinalizeHandler', () => {
|
||||
const mockParseAndValidate = jest.fn<Promise<ParseAndValidateResult>, [string, WorkflowJSON?]>();
|
||||
const mockGetErrorContext = jest.fn<string, [string, string]>();
|
||||
|
||||
const createHandler = () =>
|
||||
new AutoFinalizeHandler({
|
||||
parseAndValidate: mockParseAndValidate,
|
||||
getErrorContext: mockGetErrorContext,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetErrorContext.mockReturnValue('Error context here');
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should prompt for code creation when no code exists', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({ content: 'Some response text' });
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
|
||||
const gen = handler.execute({
|
||||
code: null,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.promptedForCode).toBe(true);
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('text editor tool');
|
||||
});
|
||||
|
||||
it('should return success with workflow when validation passes', async () => {
|
||||
const handler = createHandler();
|
||||
const messages: BaseMessage[] = [];
|
||||
|
||||
const mockNode: NodeJSON = {
|
||||
id: '1',
|
||||
name: 'Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
};
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [mockNode],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'const workflow = { ... }',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow).toEqual(mockWorkflow);
|
||||
expect(result.parseDuration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return failure with feedback when validation has warnings', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({ content: 'Some response text' });
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'W001', message: 'Warning message', nodeName: 'Node1' }],
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'const workflow = { ... }',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.workflow).toBeUndefined();
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('Validation warnings');
|
||||
});
|
||||
|
||||
it('should return failure with feedback when parsing fails', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({ content: 'Some response text' });
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Parse failed'));
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'invalid code',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.workflow).toBeUndefined();
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('Parse error');
|
||||
});
|
||||
|
||||
it('should track parse duration on failure', async () => {
|
||||
const handler = createHandler();
|
||||
const messages: BaseMessage[] = [new AIMessage({ content: 'response' })];
|
||||
|
||||
mockParseAndValidate.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
throw new Error('Parse failed');
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'invalid code',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.parseDuration).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should send only new warnings and mark them as seen via warningTracker', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({ content: 'response' });
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const seenWarning = { code: 'W001', message: 'Already seen', nodeName: 'Node1' };
|
||||
const newWarning = { code: 'W002', message: 'New warning', nodeName: 'Node2' };
|
||||
|
||||
// Mark one warning as already seen
|
||||
warningTracker.markAsSeen([seenWarning]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [seenWarning, newWarning],
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'const workflow = { ... }',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('W002');
|
||||
expect((messages[1] as ToolMessage).content).not.toContain('W001');
|
||||
// New warning should now be marked as seen
|
||||
expect(warningTracker.filterNewWarnings([newWarning])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should annotate pre-existing warnings with [pre-existing] tag', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({ content: 'Some response text' });
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const preExistingWarning = { code: 'W001', message: 'Pre-existing issue', nodeName: 'Node1' };
|
||||
const newWarning = { code: 'W002', message: 'New issue', nodeName: 'Node2' };
|
||||
|
||||
warningTracker.markAsPreExisting([preExistingWarning]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [preExistingWarning, newWarning],
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'const workflow = { ... }',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
await consumeGenerator(gen);
|
||||
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
const content = toolMessage.content as string;
|
||||
expect(content).toContain('[W001] [pre-existing] Pre-existing issue');
|
||||
expect(content).toContain('[W002] New issue');
|
||||
expect(content).not.toContain('[W002] [pre-existing]');
|
||||
});
|
||||
|
||||
it('should inject tool_use into content array when AIMessage has array content (extended thinking)', async () => {
|
||||
const handler = createHandler();
|
||||
const existingAiMessage = new AIMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Let me think...' },
|
||||
{ type: 'text', text: 'Some response' },
|
||||
],
|
||||
});
|
||||
const messages: BaseMessage[] = [existingAiMessage];
|
||||
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Parse failed'));
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'invalid code',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(messages).toHaveLength(2);
|
||||
// tool_calls should be set
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
// Content array should have tool_use block appended
|
||||
const content = (messages[0] as AIMessage).content as Array<Record<string, unknown>>;
|
||||
expect(content).toHaveLength(3);
|
||||
expect(content[2]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
name: 'validate_workflow',
|
||||
input: {},
|
||||
});
|
||||
// ToolMessage should reference same ID
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
expect(toolMessage.tool_call_id).toBe(content[2].id);
|
||||
});
|
||||
|
||||
it('should treat all-repeated warnings as success', async () => {
|
||||
const handler = createHandler();
|
||||
const messages: BaseMessage[] = [];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{ id: '1', name: 'Node', type: 'n8n-nodes-base.set', position: [0, 0], typeVersion: 1 },
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const warning = {
|
||||
code: 'AGENT_NO_SYSTEM_MESSAGE',
|
||||
message: 'No system message',
|
||||
nodeName: 'Agent',
|
||||
};
|
||||
|
||||
// Mark warning as already seen
|
||||
warningTracker.markAsSeen([warning]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [warning],
|
||||
});
|
||||
|
||||
const gen = handler.execute({
|
||||
code: 'const workflow = { ... }',
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const result = await consumeGenerator(gen);
|
||||
|
||||
// All warnings repeated → treat as success
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow).toEqual(mockWorkflow);
|
||||
// Should NOT add any feedback message
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to consume an async generator and return the final result
|
||||
async function consumeGenerator<T, R>(gen: AsyncGenerator<T, R>): Promise<R> {
|
||||
let result: IteratorResult<T, R>;
|
||||
do {
|
||||
result = await gen.next();
|
||||
} while (!result.done);
|
||||
return result.value;
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tests for FinalResponseHandler
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON, NodeJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import { WarningTracker } from '../../state/warning-tracker';
|
||||
import type { ParseAndValidateResult } from '../../types';
|
||||
import { FinalResponseHandler } from '../final-response-handler';
|
||||
|
||||
describe('FinalResponseHandler', () => {
|
||||
const mockParseAndValidate = jest.fn<Promise<ParseAndValidateResult>, [string, WorkflowJSON?]>();
|
||||
|
||||
const createHandler = () =>
|
||||
new FinalResponseHandler({
|
||||
parseAndValidate: mockParseAndValidate,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('process', () => {
|
||||
const createResponse = (content: string) => new AIMessage({ content });
|
||||
|
||||
it('should return success with workflow when validation passes', async () => {
|
||||
const handler = createHandler();
|
||||
const response = createResponse('```typescript\nconst workflow = {};\n```');
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockNode: NodeJSON = {
|
||||
id: '1',
|
||||
name: 'Node',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
};
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [mockNode],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow).toEqual(mockWorkflow);
|
||||
// Only the original response AIMessage, no feedback added
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should inject tool_call into existing AIMessage when structured output cannot be parsed', async () => {
|
||||
const handler = createHandler();
|
||||
const response = createResponse('Here is some text without code blocks');
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const result = await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.isParseError).toBe(true);
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('Could not parse');
|
||||
});
|
||||
|
||||
it('should inject tool_call into existing AIMessage when validation has warnings', async () => {
|
||||
const handler = createHandler();
|
||||
const response = createResponse('```typescript\nconst workflow = {};\n```');
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'W001', message: 'Warning message', nodeName: 'Node1' }],
|
||||
});
|
||||
|
||||
const result = await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('W001');
|
||||
});
|
||||
|
||||
it('should annotate pre-existing warnings with [pre-existing] tag', async () => {
|
||||
const handler = createHandler();
|
||||
const response = createResponse('```typescript\nconst workflow = {};\n```');
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
const mockWorkflow: WorkflowJSON = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const preExistingWarning = { code: 'W001', message: 'Pre-existing issue', nodeName: 'Node1' };
|
||||
const newWarning = { code: 'W002', message: 'New issue', nodeName: 'Node2' };
|
||||
|
||||
warningTracker.markAsPreExisting([preExistingWarning]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [preExistingWarning, newWarning],
|
||||
});
|
||||
|
||||
await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
const content = toolMessage.content as string;
|
||||
expect(content).toContain('[W001] [pre-existing] Pre-existing issue');
|
||||
expect(content).toContain('[W002] New issue');
|
||||
expect(content).not.toContain('[W002] [pre-existing]');
|
||||
});
|
||||
|
||||
it('should inject tool_use into content array when AIMessage has array content (extended thinking)', async () => {
|
||||
const handler = createHandler();
|
||||
const response = new AIMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Planning...' },
|
||||
{ type: 'text', text: '```typescript\nconst workflow = {};\n```' },
|
||||
],
|
||||
});
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Parse failed'));
|
||||
|
||||
const result = await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.isParseError).toBe(true);
|
||||
// tool_calls should be set
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
// Content array should have tool_use block appended
|
||||
const content = (messages[0] as AIMessage).content as Array<Record<string, unknown>>;
|
||||
expect(content).toHaveLength(3);
|
||||
expect(content[2]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
name: 'validate_workflow',
|
||||
input: {},
|
||||
});
|
||||
// ToolMessage should reference same ID
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
expect(toolMessage.tool_call_id).toBe(content[2].id);
|
||||
});
|
||||
|
||||
it('should inject tool_call into existing AIMessage when parsing fails', async () => {
|
||||
const handler = createHandler();
|
||||
const response = createResponse('```typescript\nconst workflow = {};\n```');
|
||||
const messages: BaseMessage[] = [response];
|
||||
const warningTracker = new WarningTracker();
|
||||
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Parse failed'));
|
||||
|
||||
const result = await handler.process({
|
||||
response,
|
||||
currentWorkflow: undefined,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.isParseError).toBe(true);
|
||||
// Should replace AIMessage with new one containing tool_call + append ToolMessage
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toBeInstanceOf(AIMessage);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toContain('parsing error');
|
||||
});
|
||||
});
|
||||
});
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Tests for ParseValidateHandler
|
||||
*/
|
||||
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { parseWorkflowCodeToBuilder, validateWorkflow, workflow } from '@n8n/workflow-sdk';
|
||||
|
||||
import { ParseValidateHandler } from '../parse-validate-handler';
|
||||
|
||||
// Mock the workflow-sdk module
|
||||
jest.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: jest.fn(),
|
||||
validateWorkflow: jest.fn(),
|
||||
workflow: { fromJSON: jest.fn() },
|
||||
stripImportStatements: jest.fn((code: string) => code),
|
||||
}));
|
||||
|
||||
// Typed mock references
|
||||
const mockParseWorkflowCodeToBuilder = parseWorkflowCodeToBuilder as jest.Mock;
|
||||
const mockValidateWorkflow = validateWorkflow as jest.Mock;
|
||||
const mockFromJSON = workflow.fromJSON as jest.Mock;
|
||||
|
||||
describe('ParseValidateHandler', () => {
|
||||
let handler: ParseValidateHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
handler = new ParseValidateHandler({});
|
||||
});
|
||||
|
||||
describe('parseAndValidate', () => {
|
||||
it('should parse valid code and return workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [{ id: 'node1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
|
||||
const result = await handler.parseAndValidate('const workflow = {}');
|
||||
|
||||
expect(result.workflow).toEqual(mockWorkflow);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
expect(mockBuilder.regenerateNodeIds).toHaveBeenCalled();
|
||||
expect(mockBuilder.validate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should collect errors from graph validation as warnings for agent self-correction', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({
|
||||
valid: false,
|
||||
errors: [{ code: 'ERR001', message: 'Graph error', nodeName: 'TestNode' }],
|
||||
warnings: [],
|
||||
}),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
|
||||
const result = await handler.parseAndValidate('code');
|
||||
|
||||
// Graph validation errors should be included as warnings for agent self-correction
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].code).toBe('ERR001');
|
||||
expect(result.warnings[0].message).toBe('Graph error');
|
||||
expect(result.warnings[0].nodeName).toBe('TestNode');
|
||||
});
|
||||
|
||||
it('should collect warnings from graph validation', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [{ code: 'WARN001', message: 'Graph warning', nodeName: 'Node1' }],
|
||||
}),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
|
||||
const result = await handler.parseAndValidate('code');
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].code).toBe('WARN001');
|
||||
});
|
||||
|
||||
it('should collect warnings from JSON validation', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [{ code: 'JSON_WARN', message: 'JSON warning' }],
|
||||
});
|
||||
|
||||
const result = await handler.parseAndValidate('code');
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].code).toBe('JSON_WARN');
|
||||
});
|
||||
|
||||
it('should collect errors from JSON validation as warnings for agent self-correction', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({
|
||||
valid: false,
|
||||
errors: [{ code: 'JSON_ERR', message: 'JSON validation error', nodeName: 'TestNode' }],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = await handler.parseAndValidate('code');
|
||||
|
||||
// JSON validation errors should be included as warnings for agent self-correction
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].code).toBe('JSON_ERR');
|
||||
expect(result.warnings[0].message).toBe('JSON validation error');
|
||||
expect(result.warnings[0].nodeName).toBe('TestNode');
|
||||
});
|
||||
|
||||
it('should collect both errors and warnings from JSON validation', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({
|
||||
valid: false,
|
||||
errors: [{ code: 'JSON_ERR', message: 'JSON error' }],
|
||||
warnings: [{ code: 'JSON_WARN', message: 'JSON warning' }],
|
||||
});
|
||||
|
||||
const result = await handler.parseAndValidate('code');
|
||||
|
||||
// Both errors and warnings should be collected for agent self-correction
|
||||
expect(result.warnings).toHaveLength(2);
|
||||
expect(result.warnings.map((w) => w.code)).toContain('JSON_ERR');
|
||||
expect(result.warnings.map((w) => w.code)).toContain('JSON_WARN');
|
||||
});
|
||||
|
||||
it('should pass currentWorkflow to generatePinData', async () => {
|
||||
const currentWorkflow = { id: 'current', name: 'Current', nodes: [], connections: {} };
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const mockBuilder = {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(mockWorkflow),
|
||||
};
|
||||
|
||||
mockParseWorkflowCodeToBuilder.mockReturnValue(mockBuilder);
|
||||
mockValidateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
|
||||
await handler.parseAndValidate('code', currentWorkflow);
|
||||
|
||||
expect(mockBuilder.generatePinData).toHaveBeenCalledWith({ beforeWorkflow: currentWorkflow });
|
||||
});
|
||||
|
||||
it('should throw on parse error', async () => {
|
||||
mockParseWorkflowCodeToBuilder.mockImplementation(() => {
|
||||
throw new Error('Syntax error at line 5');
|
||||
});
|
||||
|
||||
await expect(handler.parseAndValidate('invalid syntax')).rejects.toThrow(
|
||||
'Failed to parse generated workflow code',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorContext', () => {
|
||||
it('should extract context around error line', () => {
|
||||
const code = 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
|
||||
|
||||
const context = handler.getErrorContext(code, 'error at line 5');
|
||||
|
||||
expect(context).toContain('5:');
|
||||
expect(context).toContain('line5');
|
||||
});
|
||||
|
||||
it('should show first lines when no line number found', () => {
|
||||
const code = 'line1\nline2\nline3\nline4\nline5';
|
||||
|
||||
const context = handler.getErrorContext(code, 'generic error');
|
||||
|
||||
expect(context).toContain('1:');
|
||||
expect(context).toContain('line1');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
it('should append hint when ${{ pattern is detected near the error line', () => {
|
||||
const code = [
|
||||
'const x = node({',
|
||||
' config: {',
|
||||
' parameters: {',
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
' message: expr(`Amount: ${{ $json.amount }}`)',
|
||||
' }',
|
||||
' }',
|
||||
'});',
|
||||
].join('\n');
|
||||
|
||||
const context = handler.getErrorContext(code, 'Unexpected token at line 4');
|
||||
|
||||
expect(context).toContain('HINT:');
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
expect(context).toContain('${{');
|
||||
expect(context).toContain('single quotes');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
it('should not append hint when ${{ is not present near the error line', () => {
|
||||
const code = [
|
||||
'const x = node({',
|
||||
' config: {',
|
||||
' parameters: {',
|
||||
" message: expr('Hello {{ $json.name }}')",
|
||||
' }',
|
||||
' }',
|
||||
'});',
|
||||
].join('\n');
|
||||
|
||||
const context = handler.getErrorContext(code, 'error at line 4');
|
||||
|
||||
expect(context).not.toContain('HINT:');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
it('should not false-positive on ${{ in single-quoted expr', () => {
|
||||
const code = [
|
||||
'const x = node({',
|
||||
' config: {',
|
||||
' parameters: {',
|
||||
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
||||
" message: expr('Amount: ${{ $json.amount }}')",
|
||||
' }',
|
||||
' }',
|
||||
'});',
|
||||
].join('\n');
|
||||
|
||||
const context = handler.getErrorContext(code, 'error at line 4');
|
||||
|
||||
expect(context).not.toContain('HINT:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExistingWorkflow', () => {
|
||||
const nonEmptyJson = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ type: 'n8n-nodes-base.set' }],
|
||||
connections: {},
|
||||
} as unknown as WorkflowJSON;
|
||||
|
||||
it('should return graph warnings from an existing workflow JSON', () => {
|
||||
const mockBuilder = {
|
||||
validate: jest.fn().mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [{ code: 'WARN001', message: 'Existing warning', nodeName: 'Node1' }],
|
||||
}),
|
||||
};
|
||||
|
||||
mockFromJSON.mockReturnValue(mockBuilder);
|
||||
|
||||
const result = handler.validateExistingWorkflow(nonEmptyJson);
|
||||
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(nonEmptyJson);
|
||||
expect(mockBuilder.validate).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
code: 'WARN001',
|
||||
message: 'Existing warning',
|
||||
nodeName: 'Node1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no warnings', () => {
|
||||
const mockBuilder = {
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
};
|
||||
|
||||
mockFromJSON.mockReturnValue(mockBuilder);
|
||||
|
||||
const result = handler.validateExistingWorkflow(nonEmptyJson);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should collect both graph errors and warnings', () => {
|
||||
const mockBuilder = {
|
||||
validate: jest.fn().mockReturnValue({
|
||||
valid: false,
|
||||
errors: [{ code: 'GRAPH_ERR', message: 'Graph error' }],
|
||||
warnings: [{ code: 'GRAPH_WARN', message: 'Graph warning' }],
|
||||
}),
|
||||
};
|
||||
|
||||
mockFromJSON.mockReturnValue(mockBuilder);
|
||||
|
||||
const result = handler.validateExistingWorkflow(nonEmptyJson);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((w) => w.code)).toEqual(['GRAPH_ERR', 'GRAPH_WARN']);
|
||||
});
|
||||
|
||||
it('should skip validation and return empty array when workflow has no nodes', () => {
|
||||
const emptyJson = { id: 'test', name: 'Test', nodes: [], connections: {} };
|
||||
|
||||
const result = handler.validateExistingWorkflow(emptyJson);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mockFromJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call toJSON or validateWorkflow', () => {
|
||||
const mockBuilder = {
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
toJSON: jest.fn(),
|
||||
};
|
||||
|
||||
mockFromJSON.mockReturnValue(mockBuilder);
|
||||
|
||||
handler.validateExistingWorkflow(nonEmptyJson);
|
||||
|
||||
expect(mockBuilder.toJSON).not.toHaveBeenCalled();
|
||||
expect(mockValidateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
|
||||
import type { StreamOutput } from '../../../types/streaming';
|
||||
import type { ChatPayload } from '../../../workflow-builder-agent';
|
||||
import { SessionChatHandler } from '../session-chat-handler';
|
||||
|
||||
// Mock the session utilities
|
||||
jest.mock('../../utils/code-builder-session', () => ({
|
||||
loadCodeBuilderSession: jest.fn().mockResolvedValue({
|
||||
userMessages: [],
|
||||
previousSummary: undefined,
|
||||
}),
|
||||
saveCodeBuilderSession: jest.fn().mockResolvedValue(undefined),
|
||||
compactSessionIfNeeded: jest.fn().mockImplementation((session: unknown) => session),
|
||||
generateCodeBuilderThreadId: jest.fn().mockReturnValue('test-thread-id'),
|
||||
saveSessionMessages: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('SessionChatHandler', () => {
|
||||
let mockCheckpointer: jest.Mocked<MemorySaver>;
|
||||
let mockLlm: jest.Mocked<BaseChatModel>;
|
||||
let mockLogger: jest.Mocked<Logger>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockCheckpointer = {
|
||||
get: jest.fn(),
|
||||
put: jest.fn(),
|
||||
list: jest.fn(),
|
||||
getTuple: jest.fn(),
|
||||
} as unknown as jest.Mocked<MemorySaver>;
|
||||
|
||||
mockLlm = {
|
||||
invoke: jest.fn(),
|
||||
bindTools: jest.fn(),
|
||||
} as unknown as jest.Mocked<BaseChatModel>;
|
||||
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
} as unknown as jest.Mocked<Logger>;
|
||||
});
|
||||
|
||||
describe('onGenerationSuccess callback', () => {
|
||||
it('should call onGenerationSuccess when workflow-updated chunk is received', async () => {
|
||||
const onGenerationSuccess = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const handler = new SessionChatHandler({
|
||||
checkpointer: mockCheckpointer,
|
||||
llm: mockLlm,
|
||||
logger: mockLogger,
|
||||
onGenerationSuccess,
|
||||
});
|
||||
|
||||
const mockAgentChat = async function* (): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
yield { messages: [{ role: 'assistant', type: 'message', text: 'Planning...' }] };
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({ nodes: [], connections: {} }),
|
||||
},
|
||||
],
|
||||
};
|
||||
yield { messages: [{ type: 'session-messages', messages: [] }] };
|
||||
};
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-1',
|
||||
message: 'Create a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'wf-1', name: 'Test' },
|
||||
},
|
||||
};
|
||||
|
||||
const generator = handler.execute({
|
||||
payload,
|
||||
userId: 'user-1',
|
||||
agentChat: mockAgentChat,
|
||||
});
|
||||
|
||||
// Consume all chunks
|
||||
const chunks = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(onGenerationSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT call onGenerationSuccess when no workflow-updated chunk is received', async () => {
|
||||
const onGenerationSuccess = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const handler = new SessionChatHandler({
|
||||
checkpointer: mockCheckpointer,
|
||||
llm: mockLlm,
|
||||
logger: mockLogger,
|
||||
onGenerationSuccess,
|
||||
});
|
||||
|
||||
const mockAgentChat = async function* (): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
yield { messages: [{ role: 'assistant', type: 'message', text: 'Planning...' }] };
|
||||
yield { messages: [{ role: 'assistant', type: 'message', text: 'Failed to generate.' }] };
|
||||
};
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-2',
|
||||
message: 'Create a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'wf-2', name: 'Test' },
|
||||
},
|
||||
};
|
||||
|
||||
const generator = handler.execute({
|
||||
payload,
|
||||
userId: 'user-2',
|
||||
agentChat: mockAgentChat,
|
||||
});
|
||||
|
||||
// Consume all chunks
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(onGenerationSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log warning if onGenerationSuccess callback throws', async () => {
|
||||
const callbackError = new Error('Callback failed');
|
||||
const onGenerationSuccess = jest.fn().mockRejectedValue(callbackError);
|
||||
|
||||
const handler = new SessionChatHandler({
|
||||
checkpointer: mockCheckpointer,
|
||||
llm: mockLlm,
|
||||
logger: mockLogger,
|
||||
onGenerationSuccess,
|
||||
});
|
||||
|
||||
const mockAgentChat = async function* (): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({ nodes: [], connections: {} }),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-3',
|
||||
message: 'Create a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'wf-3', name: 'Test' },
|
||||
},
|
||||
};
|
||||
|
||||
const generator = handler.execute({
|
||||
payload,
|
||||
userId: 'user-3',
|
||||
agentChat: mockAgentChat,
|
||||
});
|
||||
|
||||
// Consume all chunks
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(onGenerationSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to execute onGenerationSuccess callback',
|
||||
{ error: callbackError },
|
||||
);
|
||||
});
|
||||
|
||||
it('should work without onGenerationSuccess callback', async () => {
|
||||
const handler = new SessionChatHandler({
|
||||
checkpointer: mockCheckpointer,
|
||||
llm: mockLlm,
|
||||
logger: mockLogger,
|
||||
// No onGenerationSuccess callback
|
||||
});
|
||||
|
||||
const mockAgentChat = async function* (): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({ nodes: [], connections: {} }),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-4',
|
||||
message: 'Create a workflow',
|
||||
workflowContext: {
|
||||
currentWorkflow: { id: 'wf-4', name: 'Test' },
|
||||
},
|
||||
};
|
||||
|
||||
const generator = handler.execute({
|
||||
payload,
|
||||
userId: 'user-4',
|
||||
agentChat: mockAgentChat,
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const chunks = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session management without workflow ID', () => {
|
||||
it('should skip session management when no workflow ID is provided', async () => {
|
||||
const onGenerationSuccess = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const handler = new SessionChatHandler({
|
||||
checkpointer: mockCheckpointer,
|
||||
llm: mockLlm,
|
||||
logger: mockLogger,
|
||||
onGenerationSuccess,
|
||||
});
|
||||
|
||||
const mockAgentChat = async function* (): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({ nodes: [], connections: {} }),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-5',
|
||||
message: 'Create a workflow',
|
||||
// No workflowContext with id
|
||||
};
|
||||
|
||||
const generator = handler.execute({
|
||||
payload,
|
||||
userId: 'user-5',
|
||||
agentChat: mockAgentChat,
|
||||
});
|
||||
|
||||
// Consume all chunks
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// When no workflow ID, session management is skipped,
|
||||
// which means onGenerationSuccess is NOT called (since that's session-managed)
|
||||
expect(onGenerationSuccess).not.toHaveBeenCalled();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('No workflow ID, skipping session management');
|
||||
});
|
||||
});
|
||||
});
|
||||
+759
@@ -0,0 +1,759 @@
|
||||
import { TextEditorHandler, findDivergenceContext } from '../text-editor-handler';
|
||||
import {
|
||||
NoMatchFoundError,
|
||||
MultipleMatchesError,
|
||||
InvalidLineNumberError,
|
||||
InvalidViewRangeError,
|
||||
InvalidPathError,
|
||||
FileExistsError,
|
||||
FileNotFoundError,
|
||||
BatchReplacementError,
|
||||
type BatchReplaceResult,
|
||||
} from '../text-editor.types';
|
||||
|
||||
describe('TextEditorHandler', () => {
|
||||
let handler: TextEditorHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new TextEditorHandler();
|
||||
});
|
||||
|
||||
describe('view command', () => {
|
||||
it('should return file content with line numbers', () => {
|
||||
const code = 'line1\nline2\nline3';
|
||||
handler.setWorkflowCode(code);
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
});
|
||||
|
||||
expect(result).toBe('1: line1\n2: line2\n3: line3');
|
||||
});
|
||||
|
||||
it('should return view_range subset with line numbers', () => {
|
||||
const code = 'line1\nline2\nline3\nline4\nline5';
|
||||
handler.setWorkflowCode(code);
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [2, 4],
|
||||
});
|
||||
|
||||
expect(result).toBe('2: line2\n3: line3\n4: line4');
|
||||
});
|
||||
|
||||
it('should clamp view_range end to file length', () => {
|
||||
const code = 'line1\nline2\nline3';
|
||||
handler.setWorkflowCode(code);
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [2, 100],
|
||||
});
|
||||
|
||||
expect(result).toBe('2: line2\n3: line3');
|
||||
});
|
||||
|
||||
it('should throw FileNotFoundError when no code exists', () => {
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
}),
|
||||
).toThrow(FileNotFoundError);
|
||||
});
|
||||
|
||||
it('should throw InvalidLineNumberError for invalid start line', () => {
|
||||
handler.setWorkflowCode('line1\nline2');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [0, 2],
|
||||
}),
|
||||
).toThrow(InvalidLineNumberError);
|
||||
});
|
||||
|
||||
it('should throw InvalidLineNumberError when start exceeds file length', () => {
|
||||
handler.setWorkflowCode('line1\nline2');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [5, 10],
|
||||
}),
|
||||
).toThrow(InvalidLineNumberError);
|
||||
});
|
||||
|
||||
it('should treat view_range end of -1 as end of file', () => {
|
||||
const code = 'line1\nline2\nline3\nline4\nline5';
|
||||
handler.setWorkflowCode(code);
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [3, -1],
|
||||
});
|
||||
|
||||
expect(result).toBe('3: line3\n4: line4\n5: line5');
|
||||
});
|
||||
|
||||
it('should return full file when view_range is [1, -1]', () => {
|
||||
const code = 'line1\nline2\nline3';
|
||||
handler.setWorkflowCode(code);
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [1, -1],
|
||||
});
|
||||
|
||||
expect(result).toBe('1: line1\n2: line2\n3: line3');
|
||||
});
|
||||
|
||||
it('should throw InvalidViewRangeError when end < start', () => {
|
||||
handler.setWorkflowCode('line1\nline2\nline3\nline4\nline5');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'view',
|
||||
path: '/workflow.js',
|
||||
view_range: [4, 2],
|
||||
}),
|
||||
).toThrow(InvalidViewRangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create command', () => {
|
||||
it('should create file when no code exists', () => {
|
||||
const result = handler.execute({
|
||||
command: 'create',
|
||||
path: '/workflow.js',
|
||||
file_text: 'const x = 1;',
|
||||
});
|
||||
|
||||
expect(result).toBe('File created successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;');
|
||||
});
|
||||
|
||||
it('should throw specific error when trying to create a different file', () => {
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'create',
|
||||
path: '/other-workflow.js',
|
||||
file_text: 'const x = 1;',
|
||||
}),
|
||||
).toThrow('Cannot create multiple workflows');
|
||||
});
|
||||
|
||||
it('should overwrite existing code', () => {
|
||||
handler.setWorkflowCode('existing content');
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'create',
|
||||
path: '/workflow.js',
|
||||
file_text: 'new content',
|
||||
});
|
||||
|
||||
expect(result).toBe('File created successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('new content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('str_replace command', () => {
|
||||
it('should replace exact single match', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;');
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const y = 2;',
|
||||
new_str: 'const y = 3;',
|
||||
});
|
||||
|
||||
expect(result).toBe('Edit applied successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;\nconst y = 3;');
|
||||
});
|
||||
|
||||
it("should handle special replacement patterns like $' in new_str", () => {
|
||||
// $' is a special pattern in String.prototype.replace() that inserts
|
||||
// the portion of the string that follows the matched substring
|
||||
handler.setWorkflowCode('const pattern = "";\nconst other = "test";');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const pattern = "";',
|
||||
new_str: "const pattern = '^\\\\d{4}-\\\\d{2}-\\\\d{2}$';",
|
||||
});
|
||||
|
||||
// Without fix: $' would cause 'const other = "test";' to be duplicated
|
||||
expect(handler.getWorkflowCode()).toBe(
|
||||
'const pattern = \'^\\\\d{4}-\\\\d{2}-\\\\d{2}$\';\nconst other = "test";',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle $& replacement pattern in new_str', () => {
|
||||
// $& inserts the matched substring
|
||||
handler.setWorkflowCode('const x = "hello";');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const x = "hello";',
|
||||
new_str: 'const x = "$&world";',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('const x = "$&world";');
|
||||
});
|
||||
|
||||
it('should handle $` replacement pattern in new_str', () => {
|
||||
// $` inserts the portion of the string that precedes the matched substring
|
||||
handler.setWorkflowCode('const prefix = "before";\nconst x = "test";');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const x = "test";',
|
||||
new_str: 'const x = "$`value";',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('const prefix = "before";\nconst x = "$`value";');
|
||||
});
|
||||
|
||||
it('should handle $$ literally in new_str', () => {
|
||||
// $$ is a special pattern in String.prototype.replace() that inserts a literal $
|
||||
// But we want literal replacement, so $$ should remain $$
|
||||
handler.setWorkflowCode('const price = 0;');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const price = 0;',
|
||||
new_str: 'const price = "$$100";',
|
||||
});
|
||||
|
||||
// With literal replacement, $$ should remain $$ in the output
|
||||
expect(handler.getWorkflowCode()).toBe('const price = "$$100";');
|
||||
});
|
||||
|
||||
it('should handle $n (capture group) patterns in new_str', () => {
|
||||
// $1, $2, etc. reference capture groups (which don't exist in literal replacement)
|
||||
handler.setWorkflowCode('const regex = /test/;');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const regex = /test/;',
|
||||
new_str: 'const regex = /($1|$2)/;',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('const regex = /($1|$2)/;');
|
||||
});
|
||||
|
||||
it('should throw NoMatchFoundError when no match found', () => {
|
||||
handler.setWorkflowCode('const x = 1;');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const y = 2;',
|
||||
new_str: 'const y = 3;',
|
||||
}),
|
||||
).toThrow(NoMatchFoundError);
|
||||
});
|
||||
|
||||
it('should throw MultipleMatchesError when multiple matches found', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst x = 1;');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const x = 1;',
|
||||
new_str: 'const x = 2;',
|
||||
}),
|
||||
).toThrow(MultipleMatchesError);
|
||||
});
|
||||
|
||||
it('should throw FileNotFoundError when no code exists', () => {
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'old',
|
||||
new_str: 'new',
|
||||
}),
|
||||
).toThrow(FileNotFoundError);
|
||||
});
|
||||
|
||||
it('should handle multiline replacements', () => {
|
||||
handler.setWorkflowCode('function foo() {\n return 1;\n}');
|
||||
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'function foo() {\n return 1;\n}',
|
||||
new_str: 'function foo() {\n return 2;\n}',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('function foo() {\n return 2;\n}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insert command', () => {
|
||||
it('should insert at beginning of file (line 0)', () => {
|
||||
handler.setWorkflowCode('line1\nline2');
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: 0,
|
||||
insert_text: 'line0',
|
||||
});
|
||||
|
||||
expect(result).toBe('Text inserted successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('line0\nline1\nline2');
|
||||
});
|
||||
|
||||
it('should insert after specified line', () => {
|
||||
handler.setWorkflowCode('line1\nline3');
|
||||
|
||||
handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: 1,
|
||||
insert_text: 'line2',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('line1\nline2\nline3');
|
||||
});
|
||||
|
||||
it('should insert at end of file', () => {
|
||||
handler.setWorkflowCode('line1\nline2');
|
||||
|
||||
handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: 2,
|
||||
insert_text: 'line3',
|
||||
});
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe('line1\nline2\nline3');
|
||||
});
|
||||
|
||||
it('should throw InvalidLineNumberError for negative line', () => {
|
||||
handler.setWorkflowCode('line1');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: -1,
|
||||
insert_text: 'new',
|
||||
}),
|
||||
).toThrow(InvalidLineNumberError);
|
||||
});
|
||||
|
||||
it('should throw InvalidLineNumberError when line exceeds file length', () => {
|
||||
handler.setWorkflowCode('line1\nline2');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: 5,
|
||||
insert_text: 'new',
|
||||
}),
|
||||
).toThrow(InvalidLineNumberError);
|
||||
});
|
||||
|
||||
it('should throw FileNotFoundError when no code exists', () => {
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'insert',
|
||||
path: '/workflow.js',
|
||||
insert_line: 0,
|
||||
insert_text: 'new',
|
||||
}),
|
||||
).toThrow(FileNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path validation', () => {
|
||||
it('should throw InvalidPathError for unsupported paths', () => {
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'view',
|
||||
path: '/other.ts',
|
||||
}),
|
||||
).toThrow(InvalidPathError);
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'create',
|
||||
path: '/src/workflow.js',
|
||||
file_text: 'code',
|
||||
}),
|
||||
).toThrow('Cannot create multiple workflows');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessor methods', () => {
|
||||
it('getWorkflowCode should return null initially', () => {
|
||||
expect(handler.getWorkflowCode()).toBeNull();
|
||||
});
|
||||
|
||||
it('setWorkflowCode should set the code', () => {
|
||||
handler.setWorkflowCode('test code');
|
||||
expect(handler.getWorkflowCode()).toBe('test code');
|
||||
});
|
||||
|
||||
it('hasWorkflowCode should return false initially', () => {
|
||||
expect(handler.hasWorkflowCode()).toBe(false);
|
||||
});
|
||||
|
||||
it('hasWorkflowCode should return true after setting code', () => {
|
||||
handler.setWorkflowCode('code');
|
||||
expect(handler.hasWorkflowCode()).toBe(true);
|
||||
});
|
||||
|
||||
it('clearWorkflowCode should clear the code', () => {
|
||||
handler.setWorkflowCode('code');
|
||||
handler.clearWorkflowCode();
|
||||
expect(handler.getWorkflowCode()).toBeNull();
|
||||
expect(handler.hasWorkflowCode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trailing newline auto-correction', () => {
|
||||
it('should auto-correct when old_str is missing trailing newline', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;\n');
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const y = 2;', // missing trailing \n
|
||||
new_str: 'const y = 3;',
|
||||
});
|
||||
|
||||
expect(result).toBe('Edit applied successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;\nconst y = 3;\n');
|
||||
});
|
||||
|
||||
it('should auto-correct when old_str has extra trailing newline', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;');
|
||||
|
||||
const result = handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const y = 2;\n', // extra trailing \n
|
||||
new_str: 'const y = 3;',
|
||||
});
|
||||
|
||||
expect(result).toBe('Edit applied successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;\nconst y = 3;');
|
||||
});
|
||||
|
||||
it('should not auto-correct when there are other differences beyond trailing newline', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;');
|
||||
|
||||
expect(() =>
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const z = 999;', // completely wrong
|
||||
new_str: 'const z = 0;',
|
||||
}),
|
||||
).toThrow(NoMatchFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages', () => {
|
||||
it('NoMatchFoundError should have descriptive message', () => {
|
||||
const error = new NoMatchFoundError('search string');
|
||||
expect(error.message).toContain('No exact match found');
|
||||
expect(error.name).toBe('NoMatchFoundError');
|
||||
});
|
||||
|
||||
it('MultipleMatchesError should include count', () => {
|
||||
const error = new MultipleMatchesError(3);
|
||||
expect(error.message).toContain('Found 3 matches');
|
||||
expect(error.name).toBe('MultipleMatchesError');
|
||||
});
|
||||
|
||||
it('InvalidLineNumberError should include line info', () => {
|
||||
const error = new InvalidLineNumberError(10, 5);
|
||||
expect(error.message).toContain('Invalid line number 10');
|
||||
expect(error.message).toContain('5 lines');
|
||||
expect(error.name).toBe('InvalidLineNumberError');
|
||||
});
|
||||
|
||||
it('InvalidViewRangeError should include start, end, and maxLine', () => {
|
||||
const error = new InvalidViewRangeError(4, 2, 10);
|
||||
expect(error.message).toContain('end (2)');
|
||||
expect(error.message).toContain('start (4)');
|
||||
expect(error.message).toContain('10 lines');
|
||||
expect(error.name).toBe('InvalidViewRangeError');
|
||||
});
|
||||
|
||||
it('InvalidPathError should include path', () => {
|
||||
const error = new InvalidPathError('/bad/path.ts');
|
||||
expect(error.message).toContain('/bad/path.ts');
|
||||
expect(error.message).toContain('/workflow.js');
|
||||
expect(error.name).toBe('InvalidPathError');
|
||||
});
|
||||
|
||||
it('FileExistsError should have descriptive message', () => {
|
||||
const error = new FileExistsError();
|
||||
expect(error.message).toContain('already exists');
|
||||
expect(error.name).toBe('FileExistsError');
|
||||
});
|
||||
|
||||
it('FileNotFoundError should have descriptive message', () => {
|
||||
const error = new FileNotFoundError();
|
||||
expect(error.message).toContain('No workflow code exists');
|
||||
expect(error.name).toBe('FileNotFoundError');
|
||||
});
|
||||
|
||||
it('BatchReplacementError should include failedIndex and cause', () => {
|
||||
const cause = new NoMatchFoundError('search');
|
||||
const error = new BatchReplacementError(2, 5, cause);
|
||||
expect(error.message).toContain('index 2 of 5');
|
||||
expect(error.message).toContain('rolled back');
|
||||
expect(error.name).toBe('BatchReplacementError');
|
||||
expect(error.failedIndex).toBe(2);
|
||||
expect(error.totalCount).toBe(5);
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeBatch', () => {
|
||||
it('should apply multiple replacements successfully', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;\nconst z = 3;');
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: 'const x = 1;', new_str: 'const x = 10;' },
|
||||
{ old_str: 'const y = 2;', new_str: 'const y = 20;' },
|
||||
{ old_str: 'const z = 3;', new_str: 'const z = 30;' },
|
||||
]);
|
||||
|
||||
expect(result).toBe('All 3 replacements applied successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 10;\nconst y = 20;\nconst z = 30;');
|
||||
});
|
||||
|
||||
it('should roll back all changes and return status array on NoMatchFound', () => {
|
||||
const originalCode = 'const a = 1;\nconst b = 2;';
|
||||
handler.setWorkflowCode(originalCode);
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: 'const a = 1;', new_str: 'const a = 10;' },
|
||||
{ old_str: 'const c = 3;', new_str: 'const c = 30;' }, // doesn't exist
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const statuses = result as BatchReplaceResult[];
|
||||
expect(statuses).toEqual([
|
||||
{ index: 0, old_str: 'const a = 1;', status: 'success' },
|
||||
{
|
||||
index: 1,
|
||||
old_str: 'const c = 3;',
|
||||
status: 'failed',
|
||||
error: expect.stringContaining('No exact match found'),
|
||||
},
|
||||
]);
|
||||
expect(handler.getWorkflowCode()).toBe(originalCode);
|
||||
});
|
||||
|
||||
it('should roll back all changes and return status array on MultipleMatches', () => {
|
||||
const originalCode = 'const x = 1;\nconst x = 1;\nconst y = 2;';
|
||||
handler.setWorkflowCode(originalCode);
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: 'const y = 2;', new_str: 'const y = 20;' },
|
||||
{ old_str: 'const x = 1;', new_str: 'const x = 10;' }, // multiple matches
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const statuses = result as BatchReplaceResult[];
|
||||
expect(statuses).toEqual([
|
||||
{ index: 0, old_str: 'const y = 2;', status: 'success' },
|
||||
{
|
||||
index: 1,
|
||||
old_str: 'const x = 1;',
|
||||
status: 'failed',
|
||||
error: expect.stringContaining('matches'),
|
||||
},
|
||||
]);
|
||||
expect(handler.getWorkflowCode()).toBe(originalCode);
|
||||
});
|
||||
|
||||
it('should handle empty replacements array', () => {
|
||||
handler.setWorkflowCode('const x = 1;');
|
||||
|
||||
const result = handler.executeBatch([]);
|
||||
|
||||
expect(result).toBe('No replacements to apply.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;');
|
||||
});
|
||||
|
||||
it('should throw FileNotFoundError when no code exists', () => {
|
||||
expect(() => handler.executeBatch([{ old_str: 'old', new_str: 'new' }])).toThrow(
|
||||
FileNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle sequential dependent replacements', () => {
|
||||
handler.setWorkflowCode('const x = 1;');
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: 'const x = 1;', new_str: 'const x = 1;\nconst y = 2;' },
|
||||
{ old_str: 'const y = 2;', new_str: 'const y = 20;' },
|
||||
]);
|
||||
|
||||
expect(result).toBe('All 2 replacements applied successfully.');
|
||||
expect(handler.getWorkflowCode()).toBe('const x = 1;\nconst y = 20;');
|
||||
});
|
||||
|
||||
it('should escape $ correctly in batch replacements', () => {
|
||||
handler.setWorkflowCode('const price = 0;\nconst tax = 0;');
|
||||
|
||||
handler.executeBatch([
|
||||
{ old_str: 'const price = 0;', new_str: "const price = '$100';" },
|
||||
{ old_str: 'const tax = 0;', new_str: "const tax = '$10';" },
|
||||
]);
|
||||
|
||||
expect(handler.getWorkflowCode()).toBe("const price = '$100';\nconst tax = '$10';");
|
||||
});
|
||||
|
||||
it('should mark remaining replacements as not_attempted after failure', () => {
|
||||
handler.setWorkflowCode('const a = 1;\nconst b = 2;\nconst c = 3;');
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: 'const a = 1;', new_str: 'const a = 10;' },
|
||||
{ old_str: 'const d = 4;', new_str: 'const d = 40;' }, // fails at index 1
|
||||
{ old_str: 'const c = 3;', new_str: 'const c = 30;' }, // not attempted
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const statuses = result as BatchReplaceResult[];
|
||||
expect(statuses).toEqual([
|
||||
{ index: 0, old_str: 'const a = 1;', status: 'success' },
|
||||
{
|
||||
index: 1,
|
||||
old_str: 'const d = 4;',
|
||||
status: 'failed',
|
||||
error: expect.stringContaining('No exact match found'),
|
||||
},
|
||||
{ index: 2, old_str: 'const c = 3;', status: 'not_attempted' },
|
||||
]);
|
||||
expect(handler.getWorkflowCode()).toBe('const a = 1;\nconst b = 2;\nconst c = 3;');
|
||||
});
|
||||
|
||||
it('should truncate old_str in results to 80 characters', () => {
|
||||
const longStr = 'a'.repeat(200);
|
||||
handler.setWorkflowCode(longStr);
|
||||
|
||||
const result = handler.executeBatch([{ old_str: longStr, new_str: 'short' }]);
|
||||
|
||||
expect(result).toBe('All 1 replacements applied successfully.');
|
||||
});
|
||||
|
||||
it('should truncate old_str preview in failure results', () => {
|
||||
const longStr = 'x'.repeat(200);
|
||||
handler.setWorkflowCode('const a = 1;');
|
||||
|
||||
const result = handler.executeBatch([
|
||||
{ old_str: longStr, new_str: 'short' }, // no match, long old_str
|
||||
]);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const statuses = result as BatchReplaceResult[];
|
||||
expect(statuses[0].old_str.length).toBeLessThanOrEqual(83); // 80 + '...'
|
||||
expect(statuses[0].old_str).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDivergenceContext', () => {
|
||||
it('should show divergence point with file lines when prefix matches', () => {
|
||||
const code = 'line1\nline2\nline3\nline4\nline5';
|
||||
const searchStr = 'line1\nline2\nline3_WRONG';
|
||||
|
||||
const result = findDivergenceContext(code, searchStr);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('line 3');
|
||||
// Should show the actual file line
|
||||
expect(result).toContain('line3');
|
||||
});
|
||||
|
||||
it('should return undefined when prefix match is too short', () => {
|
||||
const code = 'abcdefghij\nklmnop';
|
||||
const searchStr = 'xyz_completely_different';
|
||||
|
||||
const result = findDivergenceContext(code, searchStr);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should escape whitespace characters in old_str context', () => {
|
||||
const code = 'hello world\nfoo bar\n';
|
||||
const searchStr = 'hello world\nfoo baz\t';
|
||||
|
||||
const result = findDivergenceContext(code, searchStr);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Should show escaped \t in the old_str portion
|
||||
expect(result).toContain('\\t');
|
||||
});
|
||||
|
||||
it('should include match percentage', () => {
|
||||
const code = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const searchStr = 'abcdefghijklmnopqrstZZZZZ';
|
||||
|
||||
const result = findDivergenceContext(code, searchStr);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toMatch(/\d+%/);
|
||||
});
|
||||
|
||||
it('should show full file lines around divergence point', () => {
|
||||
const code = ' function foo() {\n return 1;\n }\n function bar() {\n return 2;\n }';
|
||||
const searchStr =
|
||||
' function foo() {\n return 1;\n }\n function bar() {\n return WRONG;\n }';
|
||||
|
||||
const result = findDivergenceContext(code, searchStr);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Should contain the full file line with line number
|
||||
expect(result).toContain('return 2;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('str_replace divergence diagnostics', () => {
|
||||
it('should include divergence context in NoMatchFoundError when prefix matches', () => {
|
||||
handler.setWorkflowCode('const x = 1;\nconst y = 2;\nconst z = 3;');
|
||||
|
||||
try {
|
||||
handler.execute({
|
||||
command: 'str_replace',
|
||||
path: '/workflow.js',
|
||||
old_str: 'const x = 1;\nconst y = 2;\nconst z = WRONG;',
|
||||
new_str: 'replacement',
|
||||
});
|
||||
fail('Expected NoMatchFoundError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(NoMatchFoundError);
|
||||
expect((error as NoMatchFoundError).message).toContain('No exact match found');
|
||||
// Should include divergence context with actual file line
|
||||
expect((error as NoMatchFoundError).message).toContain('const z = 3;');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+627
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Tests for TextEditorToolHandler
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ToolMessage } from '@langchain/core/messages';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
StreamOutput,
|
||||
ToolProgressChunk,
|
||||
WorkflowUpdateChunk,
|
||||
} from '../../../types/streaming';
|
||||
import { WarningTracker } from '../../state/warning-tracker';
|
||||
import { TextEditorToolHandler } from '../text-editor-tool-handler';
|
||||
|
||||
/** Type guard for ToolProgressChunk */
|
||||
function isToolProgressChunk(chunk: unknown): chunk is ToolProgressChunk {
|
||||
return (
|
||||
typeof chunk === 'object' &&
|
||||
chunk !== null &&
|
||||
'type' in chunk &&
|
||||
(chunk as ToolProgressChunk).type === 'tool'
|
||||
);
|
||||
}
|
||||
|
||||
/** Type guard for WorkflowUpdateChunk */
|
||||
function isWorkflowUpdateChunk(chunk: unknown): chunk is WorkflowUpdateChunk {
|
||||
return (
|
||||
typeof chunk === 'object' &&
|
||||
chunk !== null &&
|
||||
'type' in chunk &&
|
||||
(chunk as WorkflowUpdateChunk).type === 'workflow-updated'
|
||||
);
|
||||
}
|
||||
|
||||
describe('TextEditorToolHandler', () => {
|
||||
let handler: TextEditorToolHandler;
|
||||
let mockTextEditorExecute: jest.Mock;
|
||||
let mockTextEditorGetCode: jest.Mock;
|
||||
let mockParseAndValidate: jest.Mock;
|
||||
let mockGetErrorContext: jest.Mock;
|
||||
let messages: BaseMessage[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockTextEditorExecute = jest.fn();
|
||||
mockTextEditorGetCode = jest.fn();
|
||||
mockParseAndValidate = jest.fn();
|
||||
mockGetErrorContext = jest.fn().mockReturnValue('Code context:\n1: const x = 1;');
|
||||
messages = [];
|
||||
|
||||
handler = new TextEditorToolHandler({
|
||||
textEditorExecute: mockTextEditorExecute,
|
||||
textEditorGetCode: mockTextEditorGetCode,
|
||||
parseAndValidate: mockParseAndValidate,
|
||||
getErrorContext: mockGetErrorContext,
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
const baseParams = {
|
||||
toolCallId: 'test-id',
|
||||
args: { command: 'view', path: '/workflow.js' },
|
||||
currentWorkflow: undefined,
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
it('should execute view command and return undefined', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('1: const x = 1;');
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should yield running and completed
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should add tool result to messages
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toBe('1: const x = 1;');
|
||||
});
|
||||
|
||||
it('should execute str_replace command and return undefined', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('Edit applied successfully.');
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'str_replace', path: '/workflow.js', old_str: 'x', new_str: 'y' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toBe('Edit applied successfully.');
|
||||
});
|
||||
|
||||
it('should auto-validate after create and return workflowReady true on success', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should have create result in messages (no additional validation message on success)
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
|
||||
// Should yield a WorkflowUpdateChunk for progressive canvas rendering
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
});
|
||||
|
||||
it('should auto-validate after create and return workflowReady false on warnings', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'WARN001', message: 'Missing parameter' }],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should have single ToolMessage combining create result + validation warning
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('File created.');
|
||||
expect(content).toContain('WARN001');
|
||||
|
||||
// Should still yield a WorkflowUpdateChunk even with warnings
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
});
|
||||
|
||||
it('should auto-validate after create and return workflowReady false on parse error', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Syntax error'));
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should have single ToolMessage combining create result + parse error
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('File created.');
|
||||
expect(content).toContain('Parse error');
|
||||
|
||||
// Should NOT yield a WorkflowUpdateChunk when parse fails
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle text editor execution error', async () => {
|
||||
mockTextEditorExecute.mockImplementation(() => {
|
||||
throw new Error('No match found');
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'str_replace', path: '/workflow.js', old_str: 'x', new_str: 'y' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should add error message
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toContain('Error: No match found');
|
||||
});
|
||||
|
||||
it('should include toolCallId in all tool progress chunks', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('Done');
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
toolCallId: 'call-abc',
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolChunks = chunks.flatMap((c) => c.messages ?? []).filter(isToolProgressChunk);
|
||||
|
||||
expect(toolChunks.length).toBeGreaterThanOrEqual(2);
|
||||
for (const chunk of toolChunks) {
|
||||
expect(chunk.toolCallId).toBe('call-abc');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include toolCallId in progress chunks on error', async () => {
|
||||
mockTextEditorExecute.mockImplementation(() => {
|
||||
throw new Error('No match found');
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
toolCallId: 'call-def',
|
||||
args: { command: 'str_replace', path: '/workflow.js', old_str: 'x', new_str: 'y' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolChunks = chunks.flatMap((c) => c.messages ?? []).filter(isToolProgressChunk);
|
||||
|
||||
expect(toolChunks.length).toBeGreaterThanOrEqual(2);
|
||||
for (const chunk of toolChunks) {
|
||||
expect(chunk.toolCallId).toBe('call-def');
|
||||
}
|
||||
});
|
||||
|
||||
it('should yield tool progress chunks', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('Done');
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Find running and completed chunks
|
||||
const runningChunk = chunks.find((c: unknown) =>
|
||||
(c as { messages?: Array<{ status?: string }> }).messages?.some(
|
||||
(m) => m.status === 'running',
|
||||
),
|
||||
);
|
||||
const completedChunk = chunks.find((c: unknown) =>
|
||||
(c as { messages?: Array<{ status?: string }> }).messages?.some(
|
||||
(m) => m.status === 'completed',
|
||||
),
|
||||
);
|
||||
|
||||
expect(runningChunk).toBeDefined();
|
||||
expect(completedChunk).toBeDefined();
|
||||
});
|
||||
|
||||
it('should send only new warnings after create when warningTracker is provided', async () => {
|
||||
const warningTracker = new WarningTracker();
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const seenWarning = { code: 'WARN001', message: 'Already seen' };
|
||||
const newWarning = { code: 'WARN002', message: 'New warning' };
|
||||
|
||||
warningTracker.markAsSeen([seenWarning]);
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [seenWarning, newWarning],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should have single ToolMessage combining create result + only new warning
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('WARN002');
|
||||
expect(content).not.toContain('WARN001');
|
||||
// New warning should now be marked as seen
|
||||
expect(warningTracker.filterNewWarnings([newWarning])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should annotate pre-existing warnings with [pre-existing] tag after create', async () => {
|
||||
const warningTracker = new WarningTracker();
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const preExistingWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Pre-existing issue',
|
||||
nodeName: 'Node1',
|
||||
};
|
||||
const newWarning = { code: 'WARN002', message: 'New issue' };
|
||||
|
||||
warningTracker.markAsPreExisting([preExistingWarning]);
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [preExistingWarning, newWarning],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('[WARN001] [pre-existing] Pre-existing issue');
|
||||
expect(content).toContain('[WARN002] New issue');
|
||||
expect(content).not.toContain('[WARN002] [pre-existing]');
|
||||
});
|
||||
|
||||
it('should treat all-repeated warnings as workflowReady after create', async () => {
|
||||
const warningTracker = new WarningTracker();
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const warning = {
|
||||
code: 'AGENT_NO_SYSTEM_MESSAGE',
|
||||
message: 'No system message',
|
||||
nodeName: 'Agent',
|
||||
};
|
||||
|
||||
warningTracker.markAsSeen([warning]);
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('File created.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [warning],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'create', path: '/workflow.js', file_text: 'const workflow = {};' },
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
let iterResult = await generator.next();
|
||||
while (!iterResult.done) {
|
||||
chunks.push(iterResult.value);
|
||||
iterResult = await generator.next();
|
||||
}
|
||||
const result = iterResult.value;
|
||||
|
||||
// All warnings repeated → treat as workflowReady
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ workflowReady: true, workflow: mockWorkflow }),
|
||||
);
|
||||
// Should have single ToolMessage with create result, no validation warnings
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
|
||||
// Should yield a WorkflowUpdateChunk
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
});
|
||||
|
||||
it('should yield WorkflowUpdateChunk after successful str_replace when parse succeeds', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('Edit applied successfully.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'str_replace', path: '/workflow.js', old_str: 'x', new_str: 'y' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
|
||||
// Tool message should NOT contain parse error
|
||||
expect(messages).toHaveLength(1);
|
||||
expect((messages[0] as ToolMessage).content).toBe('Edit applied successfully.');
|
||||
});
|
||||
|
||||
it('should yield WorkflowUpdateChunk after successful insert when parse succeeds', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorExecute.mockReturnValue('Insert applied successfully.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'insert', path: '/workflow.js', insert_line: 1, new_str: 'line' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
});
|
||||
|
||||
it('should append parse error to tool message when parse fails after str_replace', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('Edit applied successfully.');
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = { broken');
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Unexpected token'));
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'str_replace', path: '/workflow.js', old_str: 'x', new_str: 'y' },
|
||||
messages,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// No WorkflowUpdateChunk should be yielded
|
||||
const workflowChunks = chunks.flatMap((c) => c.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(0);
|
||||
|
||||
// Parse error should be appended to the tool message
|
||||
expect(messages).toHaveLength(1);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('Edit applied successfully.');
|
||||
expect(content).toContain('Parse error: Unexpected token');
|
||||
});
|
||||
|
||||
it('should NOT call parse after view command', async () => {
|
||||
mockTextEditorExecute.mockReturnValue('1: const x = 1;');
|
||||
mockTextEditorGetCode.mockReturnValue('const x = 1;');
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
args: { command: 'view', path: '/workflow.js' },
|
||||
messages,
|
||||
});
|
||||
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// parseAndValidate should NOT have been called for view
|
||||
expect(mockParseAndValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseForPreview', () => {
|
||||
it('should return chunk on successful parse', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = await handler.tryParseForPreview();
|
||||
|
||||
expect(result.chunk).toBeDefined();
|
||||
expect(result.parseError).toBeUndefined();
|
||||
|
||||
const workflowChunks = (result.chunk?.messages ?? []).filter(isWorkflowUpdateChunk);
|
||||
expect(workflowChunks).toHaveLength(1);
|
||||
expect(jsonParse(workflowChunks[0].codeSnippet)).toEqual(mockWorkflow);
|
||||
});
|
||||
|
||||
it('should return parseError on parse failure', async () => {
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = { broken');
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Unexpected token'));
|
||||
|
||||
const result = await handler.tryParseForPreview();
|
||||
|
||||
expect(result.chunk).toBeUndefined();
|
||||
expect(result.parseError).toBe('Unexpected token');
|
||||
});
|
||||
|
||||
it('should return empty object when no code exists', async () => {
|
||||
mockTextEditorGetCode.mockReturnValue(null);
|
||||
|
||||
const result = await handler.tryParseForPreview();
|
||||
|
||||
expect(result.chunk).toBeUndefined();
|
||||
expect(result.parseError).toBeUndefined();
|
||||
expect(mockParseAndValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass currentWorkflow to parseAndValidate', async () => {
|
||||
const currentWorkflow = { id: 'existing', name: 'Existing', nodes: [], connections: {} };
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockTextEditorGetCode.mockReturnValue('const workflow = {};');
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await handler.tryParseForPreview(currentWorkflow);
|
||||
|
||||
expect(mockParseAndValidate).toHaveBeenCalledWith('const workflow = {};', currentWorkflow);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1037
File diff suppressed because it is too large
Load Diff
+370
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Tests for ValidateToolHandler
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ToolMessage } from '@langchain/core/messages';
|
||||
|
||||
import type { StreamOutput, ToolProgressChunk } from '../../../types/streaming';
|
||||
import { WarningTracker } from '../../state/warning-tracker';
|
||||
import { ValidateToolHandler } from '../validate-tool-handler';
|
||||
|
||||
/** Type guard for ToolProgressChunk */
|
||||
function isToolProgressChunk(chunk: unknown): chunk is ToolProgressChunk {
|
||||
return (
|
||||
typeof chunk === 'object' &&
|
||||
chunk !== null &&
|
||||
'type' in chunk &&
|
||||
(chunk as ToolProgressChunk).type === 'tool'
|
||||
);
|
||||
}
|
||||
|
||||
describe('ValidateToolHandler', () => {
|
||||
let handler: ValidateToolHandler;
|
||||
let mockParseAndValidate: jest.Mock;
|
||||
let mockGetErrorContext: jest.Mock;
|
||||
let messages: BaseMessage[];
|
||||
let warningTracker: WarningTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
mockParseAndValidate = jest.fn();
|
||||
mockGetErrorContext = jest.fn().mockReturnValue('Code context:\n1: const x = 1;');
|
||||
messages = [];
|
||||
warningTracker = new WarningTracker();
|
||||
|
||||
handler = new ValidateToolHandler({
|
||||
parseAndValidate: mockParseAndValidate,
|
||||
getErrorContext: mockGetErrorContext,
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
const baseParams = {
|
||||
toolCallId: 'test-id',
|
||||
code: 'const workflow = {};',
|
||||
currentWorkflow: undefined,
|
||||
iteration: 1,
|
||||
};
|
||||
|
||||
it('should include toolCallId in all tool progress chunks on success', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
toolCallId: 'call-val-1',
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolChunks = chunks.flatMap((c) => c.messages ?? []).filter(isToolProgressChunk);
|
||||
|
||||
expect(toolChunks.length).toBeGreaterThanOrEqual(2);
|
||||
for (const chunk of toolChunks) {
|
||||
expect(chunk.toolCallId).toBe('call-val-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include toolCallId in tool progress chunks on parse error', async () => {
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Syntax error'));
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
toolCallId: 'call-val-2',
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolChunks = chunks.flatMap((c) => c.messages ?? []).filter(isToolProgressChunk);
|
||||
|
||||
expect(toolChunks.length).toBeGreaterThanOrEqual(2);
|
||||
for (const chunk of toolChunks) {
|
||||
expect(chunk.toolCallId).toBe('call-val-2');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include toolCallId in tool progress chunks when no code', async () => {
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
toolCallId: 'call-val-3',
|
||||
code: null,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const toolChunks = chunks.flatMap((c) => c.messages ?? []).filter(isToolProgressChunk);
|
||||
|
||||
expect(toolChunks.length).toBeGreaterThanOrEqual(2);
|
||||
for (const chunk of toolChunks) {
|
||||
expect(chunk.toolCallId).toBe('call-val-3');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return workflowReady false when no code provided', async () => {
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
code: null,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should yield running and completed status
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should add error message to messages
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages[0]).toBeInstanceOf(ToolMessage);
|
||||
});
|
||||
|
||||
it('should return workflowReady true on successful validation', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [{ id: 'n1', name: 'Node 1', type: 'test' }],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should have tool running, workflow update, and tool completed
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Should add success message to messages
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toContain('Validation passed');
|
||||
});
|
||||
|
||||
it('should return workflowReady false on validation warnings (new warnings)', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'WARN001', message: 'Missing parameter' }],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should add warning message to messages
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toContain('WARN001');
|
||||
});
|
||||
|
||||
it('should return workflowReady true when all warnings are repeated', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
// Pre-mark the warning as seen
|
||||
warningTracker.markAsSeen([{ code: 'WARN001', message: 'Old message' }]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'WARN001', message: 'New message but same location' }],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should add success message (not warning) since all warnings are repeated
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toContain('Validation passed');
|
||||
});
|
||||
|
||||
it('should return workflowReady false on parse error', async () => {
|
||||
mockParseAndValidate.mockRejectedValue(new Error('Syntax error at line 5'));
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should add error message to messages
|
||||
expect(messages.length).toBe(1);
|
||||
const toolMessage = messages[0] as ToolMessage;
|
||||
expect(toolMessage.content).toContain('Parse error');
|
||||
});
|
||||
|
||||
it('should yield workflow update on success', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Find workflow update chunk
|
||||
const workflowUpdateChunk = chunks.find((c: unknown) =>
|
||||
(c as { messages?: Array<{ type: string }> }).messages?.some(
|
||||
(m) => m.type === 'workflow-updated',
|
||||
),
|
||||
);
|
||||
expect(workflowUpdateChunk).toBeDefined();
|
||||
});
|
||||
|
||||
it('should annotate pre-existing warnings with [pre-existing] tag', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const preExistingWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Pre-existing issue',
|
||||
nodeName: 'Node1',
|
||||
};
|
||||
const newWarning = { code: 'WARN002', message: 'New issue' };
|
||||
|
||||
// Mark one warning as pre-existing
|
||||
warningTracker.markAsPreExisting([preExistingWarning]);
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [preExistingWarning, newWarning],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(messages.length).toBe(1);
|
||||
const content = (messages[0] as ToolMessage).content as string;
|
||||
expect(content).toContain('[WARN001] [pre-existing] Pre-existing issue');
|
||||
expect(content).toContain('[WARN002] New issue');
|
||||
expect(content).not.toContain('[WARN002] [pre-existing]');
|
||||
});
|
||||
|
||||
it('should yield partial workflow update on warnings', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockParseAndValidate.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
warnings: [{ code: 'WARN001', message: 'Warning' }],
|
||||
});
|
||||
|
||||
const generator = handler.execute({
|
||||
...baseParams,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should still yield workflow update for progressive rendering
|
||||
const workflowUpdateChunk = chunks.find((c: unknown) =>
|
||||
(c as { messages?: Array<{ type: string }> }).messages?.some(
|
||||
(m) => m.type === 'workflow-updated',
|
||||
),
|
||||
);
|
||||
expect(workflowUpdateChunk).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Text Editor Handler
|
||||
*
|
||||
* Handles text editor tool commands for the code builder agent.
|
||||
* Implements the Anthropic str_replace_based_edit_tool interface for
|
||||
* managing workflow code as a virtual file (/workflow.js).
|
||||
*/
|
||||
|
||||
import type {
|
||||
TextEditorCommand,
|
||||
ViewCommand,
|
||||
CreateCommand,
|
||||
StrReplaceCommand,
|
||||
InsertCommand,
|
||||
StrReplacement,
|
||||
BatchReplaceResult,
|
||||
} from './text-editor.types';
|
||||
import {
|
||||
NoMatchFoundError,
|
||||
MultipleMatchesError,
|
||||
InvalidLineNumberError,
|
||||
InvalidViewRangeError,
|
||||
InvalidPathError,
|
||||
FileNotFoundError,
|
||||
} from './text-editor.types';
|
||||
|
||||
/** The only supported file path for workflow code */
|
||||
const WORKFLOW_FILE_PATH = '/workflow.js';
|
||||
|
||||
/** Max length for old_str previews in batch results */
|
||||
const PREVIEW_MAX_LENGTH = 80;
|
||||
|
||||
function truncatePreview(str: string): string {
|
||||
if (str.length <= PREVIEW_MAX_LENGTH) return str;
|
||||
return str.slice(0, PREVIEW_MAX_LENGTH) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format code with line numbers (matches view command output)
|
||||
*
|
||||
* @param code - The code to format
|
||||
* @returns Code with line numbers in "N: content" format
|
||||
*/
|
||||
export function formatCodeWithLineNumbers(code: string): string {
|
||||
const lines = code.split('\n');
|
||||
return lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
/** Min prefix length to consider a near-match useful */
|
||||
const MIN_PREFIX_LENGTH = 10;
|
||||
|
||||
/** Number of file lines to show around divergence point */
|
||||
const CONTEXT_LINES = 3;
|
||||
|
||||
/** Max chars of old_str to show around divergence */
|
||||
const OLD_STR_CONTEXT_LENGTH = 40;
|
||||
|
||||
function escapeWhitespace(str: string): string {
|
||||
return str.replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find where old_str diverges from the code and return diagnostic context.
|
||||
* Uses binary search for the longest prefix of searchStr that exists in code,
|
||||
* then shows the divergence point with actual file lines.
|
||||
*/
|
||||
export function findDivergenceContext(code: string, searchStr: string): string | undefined {
|
||||
// Binary search for longest matching prefix
|
||||
let lo = 0;
|
||||
let hi = searchStr.length;
|
||||
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (code.includes(searchStr.substring(0, mid))) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const matchLength = lo;
|
||||
if (matchLength < MIN_PREFIX_LENGTH) return undefined;
|
||||
|
||||
const matchPos = code.indexOf(searchStr.substring(0, matchLength));
|
||||
const divergePos = matchPos + matchLength;
|
||||
const percentage = Math.round((matchLength / searchStr.length) * 100);
|
||||
|
||||
// Compute divergence line number (1-indexed)
|
||||
const codeUpToDiverge = code.substring(0, divergePos);
|
||||
const divergeLine = codeUpToDiverge.split('\n').length;
|
||||
|
||||
// Extract old_str context around divergence
|
||||
const oldStrRemainder = searchStr.substring(matchLength, matchLength + OLD_STR_CONTEXT_LENGTH);
|
||||
const oldStrPrefix = searchStr.substring(Math.max(0, matchLength - 20), matchLength);
|
||||
|
||||
// Extract full file lines around divergence
|
||||
const codeLines = code.split('\n');
|
||||
const startLine = Math.max(0, divergeLine - CONTEXT_LINES);
|
||||
const endLine = Math.min(codeLines.length, divergeLine + 1);
|
||||
const fileContext = codeLines
|
||||
.slice(startLine, endLine)
|
||||
.map((line, i) => ` ${startLine + i + 1}: ${line}`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
`Closest match (${percentage}% of old_str matched, diverges at line ${divergeLine}):\n` +
|
||||
` old_str: ...${escapeWhitespace(oldStrPrefix)}>>> ${escapeWhitespace(oldStrRemainder)}\n` +
|
||||
` file:\n${fileContext}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for text editor tool commands
|
||||
*
|
||||
* Manages a single virtual file (/workflow.js) containing workflow SDK code.
|
||||
* Supports view, create, str_replace, and insert commands.
|
||||
*/
|
||||
export class TextEditorHandler {
|
||||
private code: string | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Execute a text editor command
|
||||
*
|
||||
* @param command - The command to execute
|
||||
* @returns Result message for the LLM
|
||||
* @throws Various errors for invalid operations
|
||||
*/
|
||||
execute(command: TextEditorCommand): string {
|
||||
// Create-specific path validation with a more helpful error message
|
||||
if (command.command === 'create' && command.path !== WORKFLOW_FILE_PATH) {
|
||||
throw new Error(
|
||||
'Cannot create multiple workflows. You can only extend the existing workflow at /workflow.js.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate path for all commands
|
||||
this.validatePath(command.path);
|
||||
|
||||
let result: string;
|
||||
switch (command.command) {
|
||||
case 'view':
|
||||
result = this.handleView(command);
|
||||
break;
|
||||
case 'create':
|
||||
result = this.handleCreate(command);
|
||||
break;
|
||||
case 'str_replace':
|
||||
result = this.handleStrReplace(command);
|
||||
break;
|
||||
case 'insert':
|
||||
result = this.handleInsert(command);
|
||||
break;
|
||||
default:
|
||||
result = `Unknown command: ${(command as { command: string }).command}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the path is the supported workflow file
|
||||
*/
|
||||
private validatePath(path: string): void {
|
||||
if (path !== WORKFLOW_FILE_PATH) {
|
||||
throw new InvalidPathError(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle view command - display file content with line numbers
|
||||
*/
|
||||
private handleView(command: ViewCommand): string {
|
||||
if (!this.code) {
|
||||
throw new FileNotFoundError();
|
||||
}
|
||||
|
||||
const lines = this.code.split('\n');
|
||||
|
||||
// Handle view_range if specified
|
||||
if (command.view_range) {
|
||||
const [start, rawEnd] = command.view_range;
|
||||
const end = rawEnd === -1 ? lines.length : rawEnd;
|
||||
|
||||
// Validate range (1-indexed)
|
||||
if (start < 1 || start > lines.length) {
|
||||
throw new InvalidLineNumberError(start, lines.length);
|
||||
}
|
||||
if (end < start) {
|
||||
throw new InvalidViewRangeError(start, end, lines.length);
|
||||
}
|
||||
|
||||
// Convert to 0-indexed and extract range
|
||||
const startIdx = start - 1;
|
||||
const endIdx = Math.min(end, lines.length);
|
||||
const selectedLines = lines.slice(startIdx, endIdx);
|
||||
|
||||
return selectedLines.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
// Return full file with line numbers
|
||||
return formatCodeWithLineNumbers(this.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle create command - create or overwrite the workflow file
|
||||
*/
|
||||
private handleCreate(command: CreateCommand): string {
|
||||
this.code = command.file_text;
|
||||
return 'File created successfully.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle str_replace command - replace exact string match
|
||||
*/
|
||||
private handleStrReplace(command: StrReplaceCommand): string {
|
||||
if (this.code === null) {
|
||||
throw new FileNotFoundError();
|
||||
}
|
||||
|
||||
const { old_str, new_str } = command;
|
||||
|
||||
// Count occurrences
|
||||
const count = this.countOccurrences(this.code, old_str);
|
||||
|
||||
if (count === 0) {
|
||||
// Try toggling trailing newline — common LLM mistake
|
||||
const normalized = old_str.endsWith('\n') ? old_str.slice(0, -1) : old_str + '\n';
|
||||
const normalizedCount = this.countOccurrences(this.code, normalized);
|
||||
if (normalizedCount === 1) {
|
||||
const escapedNew = new_str.replace(/\$/g, '$$$$');
|
||||
this.code = this.code.replace(normalized, escapedNew);
|
||||
return 'Edit applied successfully.';
|
||||
}
|
||||
|
||||
const context = findDivergenceContext(this.code, old_str);
|
||||
throw new NoMatchFoundError(old_str, context);
|
||||
}
|
||||
|
||||
if (count > 1) {
|
||||
throw new MultipleMatchesError(count);
|
||||
}
|
||||
|
||||
// Replace the single occurrence
|
||||
// Escape $ characters in new_str to prevent special replacement patterns
|
||||
// ($', $&, $`, $1-$9) from being interpreted by String.prototype.replace()
|
||||
const escapedNewStr = new_str.replace(/\$/g, '$$$$');
|
||||
this.code = this.code.replace(old_str, escapedNewStr);
|
||||
return 'Edit applied successfully.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle insert command - insert text at specific line
|
||||
*/
|
||||
private handleInsert(command: InsertCommand): string {
|
||||
if (this.code === null) {
|
||||
throw new FileNotFoundError();
|
||||
}
|
||||
|
||||
const { insert_line, insert_text } = command;
|
||||
const lines = this.code.split('\n');
|
||||
|
||||
// Validate line number (0 = beginning, 1-n = after that line)
|
||||
if (insert_line < 0 || insert_line > lines.length) {
|
||||
throw new InvalidLineNumberError(insert_line, lines.length);
|
||||
}
|
||||
|
||||
// Insert at the specified position
|
||||
lines.splice(insert_line, 0, insert_text);
|
||||
this.code = lines.join('\n');
|
||||
|
||||
return 'Text inserted successfully.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Count non-overlapping occurrences of a substring
|
||||
*/
|
||||
private countOccurrences(text: string, search: string): number {
|
||||
if (search.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
|
||||
while ((pos = text.indexOf(search, pos)) !== -1) {
|
||||
count++;
|
||||
pos += search.length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply multiple str_replace operations atomically.
|
||||
* Rolls back all changes if any single replacement fails.
|
||||
*
|
||||
* @param replacements - Ordered list of replacements to apply
|
||||
* @returns Success message string when all succeed, or BatchReplaceResult[] on failure
|
||||
* @throws FileNotFoundError if no code exists
|
||||
*/
|
||||
executeBatch(replacements: StrReplacement[]): string | BatchReplaceResult[] {
|
||||
if (this.code === null) {
|
||||
throw new FileNotFoundError();
|
||||
}
|
||||
|
||||
if (replacements.length === 0) {
|
||||
return 'No replacements to apply.';
|
||||
}
|
||||
|
||||
const snapshot = this.code;
|
||||
const results: BatchReplaceResult[] = [];
|
||||
|
||||
for (let i = 0; i < replacements.length; i++) {
|
||||
const { old_str, new_str } = replacements[i];
|
||||
const preview = truncatePreview(old_str);
|
||||
const count = this.countOccurrences(this.code, old_str);
|
||||
|
||||
if (count === 0) {
|
||||
this.code = snapshot;
|
||||
results.push({
|
||||
index: i,
|
||||
old_str: preview,
|
||||
status: 'failed',
|
||||
error: new NoMatchFoundError(old_str).message,
|
||||
});
|
||||
for (let j = i + 1; j < replacements.length; j++) {
|
||||
results.push({
|
||||
index: j,
|
||||
old_str: truncatePreview(replacements[j].old_str),
|
||||
status: 'not_attempted',
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (count > 1) {
|
||||
this.code = snapshot;
|
||||
results.push({
|
||||
index: i,
|
||||
old_str: preview,
|
||||
status: 'failed',
|
||||
error: new MultipleMatchesError(count).message,
|
||||
});
|
||||
for (let j = i + 1; j < replacements.length; j++) {
|
||||
results.push({
|
||||
index: j,
|
||||
old_str: truncatePreview(replacements[j].old_str),
|
||||
status: 'not_attempted',
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const escapedNewStr = new_str.replace(/\$/g, '$$$$');
|
||||
this.code = this.code.replace(old_str, escapedNewStr);
|
||||
results.push({ index: i, old_str: preview, status: 'success' });
|
||||
}
|
||||
|
||||
return `All ${replacements.length} replacements applied successfully.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current workflow code
|
||||
*/
|
||||
getWorkflowCode(): string | null {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the workflow code (for pre-populating with existing workflow)
|
||||
*/
|
||||
setWorkflowCode(code: string): void {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow code exists
|
||||
*/
|
||||
hasWorkflowCode(): boolean {
|
||||
return this.code !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the workflow code
|
||||
*/
|
||||
clearWorkflowCode(): void {
|
||||
this.code = null;
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Text Editor Tool Handler
|
||||
*
|
||||
* Handles the str_replace_based_edit_tool execution. Wraps the TextEditorHandler
|
||||
* and adds progress streaming, auto-validation after create, and error handling.
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ToolMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { StreamOutput, ToolProgressChunk, WorkflowUpdateChunk } from '../../types/streaming';
|
||||
import { FIX_VALIDATION_ERRORS_INSTRUCTION } from '../constants';
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
import type { ParseAndValidateResult } from '../types';
|
||||
import { formatWarnings } from '../utils/format-warnings';
|
||||
|
||||
/**
|
||||
* Text editor execute function type
|
||||
*/
|
||||
type TextEditorExecuteFn = (args: Record<string, unknown>) => string;
|
||||
|
||||
/**
|
||||
* Text editor get code function type
|
||||
*/
|
||||
type TextEditorGetCodeFn = () => string | null;
|
||||
|
||||
/**
|
||||
* Parse and validate function type
|
||||
*/
|
||||
type ParseAndValidateFn = (
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
) => Promise<ParseAndValidateResult>;
|
||||
|
||||
/**
|
||||
* Get error context function type
|
||||
*/
|
||||
type GetErrorContextFn = (code: string, errorMessage: string) => string;
|
||||
|
||||
/**
|
||||
* Configuration for TextEditorToolHandler
|
||||
*/
|
||||
export interface TextEditorToolHandlerConfig {
|
||||
textEditorExecute: TextEditorExecuteFn;
|
||||
textEditorGetCode: TextEditorGetCodeFn;
|
||||
parseAndValidate: ParseAndValidateFn;
|
||||
getErrorContext: GetErrorContextFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for executing the text editor tool
|
||||
*/
|
||||
export interface TextEditorToolParams {
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
currentWorkflow: WorkflowJSON | undefined;
|
||||
iteration: number;
|
||||
messages: BaseMessage[];
|
||||
/** Optional warning tracker for deduplicating repeated warnings */
|
||||
warningTracker?: WarningTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of text editor tool execution
|
||||
*/
|
||||
export interface TextEditorToolResult {
|
||||
workflowReady?: boolean;
|
||||
workflow?: WorkflowJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a preview parse attempt after an edit command
|
||||
*/
|
||||
export interface PreviewParseResult {
|
||||
/** WorkflowUpdateChunk if parse succeeded */
|
||||
chunk?: StreamOutput;
|
||||
/** Error message if parse failed */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the str_replace_based_edit_tool execution.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Executes text editor commands (view, str_replace, create, insert)
|
||||
* 2. Auto-validates after create command
|
||||
* 3. Yields progress chunks
|
||||
* 4. Handles errors gracefully
|
||||
*/
|
||||
export class TextEditorToolHandler {
|
||||
private textEditorExecute: TextEditorExecuteFn;
|
||||
private textEditorGetCode: TextEditorGetCodeFn;
|
||||
private parseAndValidate: ParseAndValidateFn;
|
||||
private getErrorContext: GetErrorContextFn;
|
||||
|
||||
constructor(config: TextEditorToolHandlerConfig) {
|
||||
this.textEditorExecute = config.textEditorExecute;
|
||||
this.textEditorGetCode = config.textEditorGetCode;
|
||||
this.parseAndValidate = config.parseAndValidate;
|
||||
this.getErrorContext = config.getErrorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the text editor tool.
|
||||
*
|
||||
* @param params - Execution parameters
|
||||
* @yields StreamOutput chunks for tool progress
|
||||
* @returns TextEditorToolResult with optional workflowReady status
|
||||
*/
|
||||
async *execute(
|
||||
params: TextEditorToolParams,
|
||||
): AsyncGenerator<StreamOutput, TextEditorToolResult | undefined, unknown> {
|
||||
const { toolCallId, args, currentWorkflow, iteration, messages, warningTracker } = params;
|
||||
|
||||
const command = args.command as string;
|
||||
|
||||
// Stream tool progress - running
|
||||
yield this.createToolProgressChunk('running', command, toolCallId);
|
||||
|
||||
try {
|
||||
// Execute the text editor command
|
||||
const result = this.textEditorExecute(args);
|
||||
|
||||
// Auto-validate after create command — combines create result + validation into single ToolMessage
|
||||
if (command === 'create') {
|
||||
const autoValidateResult = await this.autoValidateAfterCreate(
|
||||
toolCallId,
|
||||
result,
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
messages,
|
||||
warningTracker,
|
||||
);
|
||||
if (autoValidateResult.workflow) {
|
||||
yield this.createWorkflowUpdateChunk(autoValidateResult.workflow);
|
||||
}
|
||||
|
||||
yield this.createToolProgressChunk('completed', command, toolCallId);
|
||||
return autoValidateResult;
|
||||
}
|
||||
|
||||
// Add tool result to messages (non-create commands)
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: result,
|
||||
}),
|
||||
);
|
||||
|
||||
// Preview parse after edit commands for progressive canvas rendering
|
||||
if (command !== 'view') {
|
||||
const preview = await this.tryParseForPreview(currentWorkflow);
|
||||
if (preview.chunk) {
|
||||
yield preview.chunk;
|
||||
}
|
||||
if (preview.parseError) {
|
||||
const lastMsg = messages[messages.length - 1] as ToolMessage;
|
||||
lastMsg.content = `${lastMsg.content as string}\n\nParse error: ${preview.parseError}`;
|
||||
}
|
||||
}
|
||||
|
||||
yield this.createToolProgressChunk('completed', command, toolCallId);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Add error message to messages
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: `Error: ${errorMessage}`,
|
||||
}),
|
||||
);
|
||||
|
||||
yield this.createToolProgressChunk('completed', command, toolCallId);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-validate after create command
|
||||
*/
|
||||
private async autoValidateAfterCreate(
|
||||
toolCallId: string,
|
||||
createResult: string,
|
||||
currentWorkflow: WorkflowJSON | undefined,
|
||||
_iteration: number,
|
||||
messages: BaseMessage[],
|
||||
warningTracker?: WarningTracker,
|
||||
): Promise<TextEditorToolResult> {
|
||||
const code = this.textEditorGetCode();
|
||||
|
||||
if (!code) {
|
||||
messages.push(new ToolMessage({ tool_call_id: toolCallId, content: createResult }));
|
||||
return { workflowReady: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.parseAndValidate(code, currentWorkflow);
|
||||
|
||||
// Handle warnings
|
||||
if (result.warnings.length > 0) {
|
||||
const newWarnings = warningTracker
|
||||
? warningTracker.filterNewWarnings(result.warnings)
|
||||
: result.warnings;
|
||||
|
||||
if (newWarnings.length > 0) {
|
||||
if (warningTracker) {
|
||||
warningTracker.markAsSeen(newWarnings);
|
||||
}
|
||||
|
||||
const warningText = formatWarnings(newWarnings, warningTracker);
|
||||
const errorContext = this.getErrorContext(code, newWarnings[0].message);
|
||||
|
||||
// Combine create result + validation warnings into single ToolMessage
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: `${createResult}\n\nValidation warnings:\n${warningText}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
workflowReady: false,
|
||||
workflow: result.workflow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validation passed — push create result as ToolMessage
|
||||
messages.push(new ToolMessage({ tool_call_id: toolCallId, content: createResult }));
|
||||
|
||||
return {
|
||||
workflowReady: true,
|
||||
workflow: result.workflow,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorContext = this.getErrorContext(code, errorMessage);
|
||||
|
||||
// Combine create result + parse error into single ToolMessage
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: `${createResult}\n\nParse error: ${errorMessage}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
additional_kwargs: { validationMessage: true },
|
||||
}),
|
||||
);
|
||||
|
||||
return { workflowReady: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try parsing the current code for a preview update.
|
||||
*
|
||||
* On success: returns a WorkflowUpdateChunk for progressive canvas rendering.
|
||||
* On error: returns the parse error message so the agent can self-correct.
|
||||
* If no code exists: returns empty object.
|
||||
*/
|
||||
async tryParseForPreview(currentWorkflow?: WorkflowJSON): Promise<PreviewParseResult> {
|
||||
const code = this.textEditorGetCode();
|
||||
if (!code) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.parseAndValidate(code, currentWorkflow);
|
||||
return { chunk: this.createWorkflowUpdateChunk(result.workflow) };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { parseError: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool progress chunk
|
||||
*/
|
||||
private createToolProgressChunk(
|
||||
status: 'running' | 'completed',
|
||||
command: string,
|
||||
toolCallId: string,
|
||||
): StreamOutput {
|
||||
const displayTitle = command === 'view' ? 'Viewing workflow' : 'Editing workflow';
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: 'str_replace_based_edit_tool',
|
||||
toolCallId,
|
||||
displayTitle,
|
||||
status,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow update chunk for progressive canvas rendering
|
||||
*/
|
||||
private createWorkflowUpdateChunk(workflow: WorkflowJSON): StreamOutput {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify(workflow, null, 2),
|
||||
} as WorkflowUpdateChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Text Editor Tool Types
|
||||
*
|
||||
* Type definitions for the Anthropic str_replace_based_edit_tool (text_editor_20250728)
|
||||
* used by the code builder agent for targeted workflow code edits.
|
||||
*/
|
||||
|
||||
/**
|
||||
* View command - displays file content with line numbers
|
||||
*/
|
||||
export interface ViewCommand {
|
||||
command: 'view';
|
||||
path: string;
|
||||
/** Optional line range to view [start, end] (1-indexed, inclusive) */
|
||||
view_range?: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command - creates a new file with content
|
||||
*/
|
||||
export interface CreateCommand {
|
||||
command: 'create';
|
||||
path: string;
|
||||
file_text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* String replace command - replaces exact text match
|
||||
*/
|
||||
export interface StrReplaceCommand {
|
||||
command: 'str_replace';
|
||||
path: string;
|
||||
old_str: string;
|
||||
new_str: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert command - inserts text at a specific line
|
||||
*/
|
||||
export interface InsertCommand {
|
||||
command: 'insert';
|
||||
path: string;
|
||||
/** Line number after which to insert (0 = beginning of file) */
|
||||
insert_line: number;
|
||||
insert_text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all text editor commands
|
||||
*/
|
||||
export type TextEditorCommand = ViewCommand | CreateCommand | StrReplaceCommand | InsertCommand;
|
||||
|
||||
/**
|
||||
* Text editor tool call from LLM response
|
||||
*/
|
||||
export interface TextEditorToolCall {
|
||||
name: 'str_replace_based_edit_tool';
|
||||
args: TextEditorCommand;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from text editor command execution
|
||||
*/
|
||||
export interface TextEditorResult {
|
||||
/** Result message to send back to the LLM */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when no match is found for str_replace
|
||||
*/
|
||||
export class NoMatchFoundError extends Error {
|
||||
constructor(_searchStr: string, nearMatchContext?: string) {
|
||||
const base =
|
||||
'No exact match found for str_replace. The old_str content was not found in the file.';
|
||||
const message = nearMatchContext ? `${base}\n${nearMatchContext}` : base;
|
||||
super(message);
|
||||
this.name = 'NoMatchFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when multiple matches are found for str_replace
|
||||
*/
|
||||
export class MultipleMatchesError extends Error {
|
||||
constructor(count: number) {
|
||||
super(`Found ${count} matches. Please provide more context to make the replacement unique.`);
|
||||
this.name = 'MultipleMatchesError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown for invalid line numbers
|
||||
*/
|
||||
export class InvalidLineNumberError extends Error {
|
||||
constructor(line: number, maxLine: number) {
|
||||
super(`Invalid line number ${line}. File has ${maxLine} lines (valid range: 0-${maxLine}).`);
|
||||
this.name = 'InvalidLineNumberError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when view_range end is less than start
|
||||
*/
|
||||
export class InvalidViewRangeError extends Error {
|
||||
constructor(start: number, end: number, maxLine: number) {
|
||||
super(
|
||||
`Invalid view range: end (${end}) must be >= start (${start}). File has ${maxLine} lines (valid range: 1-${maxLine}).`,
|
||||
);
|
||||
this.name = 'InvalidViewRangeError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown for invalid file paths
|
||||
*/
|
||||
export class InvalidPathError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Invalid path "${path}". Only /workflow.js is supported.`);
|
||||
this.name = 'InvalidPathError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when file already exists for create command
|
||||
*/
|
||||
export class FileExistsError extends Error {
|
||||
constructor() {
|
||||
super('File already exists. Use text editor tools to modify existing content.');
|
||||
this.name = 'FileExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when file doesn't exist for edit commands
|
||||
*/
|
||||
export class FileNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('No workflow code exists yet. Use create first.');
|
||||
this.name = 'FileNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single replacement in a batch operation
|
||||
*/
|
||||
export interface StrReplacement {
|
||||
old_str: string;
|
||||
new_str: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-replacement result in a batch operation.
|
||||
* Returned as an array when any replacement fails.
|
||||
*/
|
||||
export interface BatchReplaceResult {
|
||||
index: number;
|
||||
/** Truncated preview of old_str for context */
|
||||
old_str: string;
|
||||
status: 'success' | 'failed' | 'not_attempted';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a batch replacement fails.
|
||||
* Contains the index that failed and the underlying cause.
|
||||
*/
|
||||
export class BatchReplacementError extends Error {
|
||||
readonly failedIndex: number;
|
||||
readonly totalCount: number;
|
||||
override readonly cause: NoMatchFoundError | MultipleMatchesError;
|
||||
|
||||
constructor(
|
||||
failedIndex: number,
|
||||
totalCount: number,
|
||||
cause: NoMatchFoundError | MultipleMatchesError,
|
||||
) {
|
||||
super(
|
||||
`Batch replacement failed at index ${failedIndex} of ${totalCount}: ${cause.message}. All changes have been rolled back.`,
|
||||
);
|
||||
this.name = 'BatchReplacementError';
|
||||
this.failedIndex = failedIndex;
|
||||
this.totalCount = totalCount;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Tool Dispatch Handler
|
||||
*
|
||||
* Handles the routing of tool calls to appropriate handlers during the chat loop.
|
||||
* Extracts tool call processing logic to reduce cyclomatic complexity in chat().
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ToolMessage } from '@langchain/core/messages';
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { TextEditorHandler } from './text-editor-handler';
|
||||
import type { TextEditorToolHandler } from './text-editor-tool-handler';
|
||||
import type { StrReplacement } from './text-editor.types';
|
||||
import type { ValidateToolHandler } from './validate-tool-handler';
|
||||
import type { StreamOutput, ToolProgressChunk } from '../../types/streaming';
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
|
||||
/**
|
||||
* Parse and validate the `replacements` argument from LLM tool calls.
|
||||
* Handles the case where the LLM sends a JSON string instead of an array.
|
||||
*/
|
||||
export function parseReplacements(raw: unknown): StrReplacement[] {
|
||||
let parsed: unknown = raw;
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'replacements must be a JSON array of {old_str, new_str} objects, but received an invalid JSON string.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
'replacements must be an array of {old_str, new_str} objects. Example: {"replacements": [{"old_str": "foo", "new_str": "bar"}]}',
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i] as Record<string, unknown>;
|
||||
if (typeof item?.old_str !== 'string') {
|
||||
throw new Error(
|
||||
`replacements[${i}] is missing a valid "old_str" string. Each replacement must have {old_str: string, new_str: string}.`,
|
||||
);
|
||||
}
|
||||
if (typeof item?.new_str !== 'string') {
|
||||
throw new Error(
|
||||
`replacements[${i}] is missing a valid "new_str" string. Each replacement must have {old_str: string, new_str: string}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed as StrReplacement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call structure from LLM response
|
||||
*/
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for ToolDispatchHandler
|
||||
*/
|
||||
export interface ToolDispatchHandlerConfig {
|
||||
toolsMap: Map<string, StructuredToolInterface>;
|
||||
toolDisplayTitles?: Map<string, string>;
|
||||
validateToolHandler: ValidateToolHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for dispatching tool calls
|
||||
*/
|
||||
export interface ToolDispatchParams {
|
||||
toolCalls: ToolCall[];
|
||||
messages: BaseMessage[];
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
iteration: number;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
warningTracker: WarningTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of tool dispatch
|
||||
*/
|
||||
export interface ToolDispatchResult {
|
||||
workflow?: WorkflowJSON;
|
||||
workflowReady: boolean;
|
||||
sourceCode?: string;
|
||||
parseDuration?: number;
|
||||
validatePassedThisIteration: boolean;
|
||||
/** undefined = no edits or validations this iteration */
|
||||
hasUnvalidatedEdits?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the routing of tool calls to appropriate handlers.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Routes tool calls to appropriate handlers (text editor, validate, or general)
|
||||
* 2. Tracks workflow state updates from tool results
|
||||
* 3. Yields progress chunks for each tool call
|
||||
* 4. Returns aggregated result with workflow state
|
||||
*/
|
||||
export class ToolDispatchHandler {
|
||||
private toolsMap: Map<string, StructuredToolInterface>;
|
||||
private toolDisplayTitles?: Map<string, string>;
|
||||
private validateToolHandler: ValidateToolHandler;
|
||||
|
||||
constructor(config: ToolDispatchHandlerConfig) {
|
||||
this.toolsMap = config.toolsMap;
|
||||
this.toolDisplayTitles = config.toolDisplayTitles;
|
||||
this.validateToolHandler = config.validateToolHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch tool calls to appropriate handlers.
|
||||
*
|
||||
* @param params - Dispatch parameters
|
||||
* @yields StreamOutput chunks for tool progress
|
||||
* @returns ToolDispatchResult with workflow state
|
||||
*/
|
||||
async *dispatch(
|
||||
params: ToolDispatchParams,
|
||||
): AsyncGenerator<StreamOutput, ToolDispatchResult, unknown> {
|
||||
const {
|
||||
toolCalls,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
warningTracker,
|
||||
} = params;
|
||||
|
||||
let workflow: WorkflowJSON | undefined;
|
||||
let workflowReady = false;
|
||||
let sourceCode: string | undefined;
|
||||
let parseDuration: number | undefined;
|
||||
let validatePassedThisIteration = false;
|
||||
let hasUnvalidatedEdits: boolean | undefined;
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
// Skip tool calls without an ID (shouldn't happen but handle gracefully)
|
||||
if (!toolCall.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = yield* this.executeToolCall({
|
||||
toolCall,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
// Track hasUnvalidatedEdits based on tool call type
|
||||
if (toolCall.name === 'str_replace_based_edit_tool') {
|
||||
const command = toolCall.args.command as string;
|
||||
if (command === 'create') {
|
||||
hasUnvalidatedEdits = false; // create auto-validates
|
||||
} else if (command !== 'view') {
|
||||
hasUnvalidatedEdits = true; // str_replace, insert modify code
|
||||
}
|
||||
}
|
||||
if (toolCall.name === 'batch_str_replace') {
|
||||
hasUnvalidatedEdits = true;
|
||||
}
|
||||
if (toolCall.name === 'validate_workflow') {
|
||||
hasUnvalidatedEdits = false;
|
||||
}
|
||||
|
||||
// Update state from result
|
||||
if (result.workflow) {
|
||||
workflow = result.workflow;
|
||||
}
|
||||
if (result.parseDuration !== undefined) {
|
||||
parseDuration = result.parseDuration;
|
||||
}
|
||||
if (result.workflowReady) {
|
||||
workflowReady = true;
|
||||
break;
|
||||
}
|
||||
if (result.validatePassed) {
|
||||
validatePassedThisIteration = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workflowReady,
|
||||
sourceCode,
|
||||
parseDuration,
|
||||
validatePassedThisIteration,
|
||||
hasUnvalidatedEdits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call
|
||||
*/
|
||||
private async *executeToolCall(params: {
|
||||
toolCall: ToolCall;
|
||||
messages: BaseMessage[];
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
iteration: number;
|
||||
textEditorHandler?: TextEditorHandler;
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
warningTracker: WarningTracker;
|
||||
}): AsyncGenerator<
|
||||
StreamOutput,
|
||||
{
|
||||
workflow?: WorkflowJSON;
|
||||
workflowReady?: boolean;
|
||||
parseDuration?: number;
|
||||
validatePassed?: boolean;
|
||||
},
|
||||
unknown
|
||||
> {
|
||||
const {
|
||||
toolCall,
|
||||
messages,
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
textEditorHandler,
|
||||
textEditorToolHandler,
|
||||
warningTracker,
|
||||
} = params;
|
||||
|
||||
// Handle text editor tool calls
|
||||
if (toolCall.name === 'str_replace_based_edit_tool' && textEditorToolHandler) {
|
||||
const result = yield* textEditorToolHandler.execute({
|
||||
toolCallId: toolCall.id!,
|
||||
args: toolCall.args,
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
workflow: result.workflow,
|
||||
workflowReady: result.workflowReady,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Handle batch str_replace tool calls
|
||||
if (toolCall.name === 'batch_str_replace' && textEditorHandler) {
|
||||
yield* this.executeBatchStrReplace({
|
||||
toolCall,
|
||||
textEditorHandler,
|
||||
messages,
|
||||
textEditorToolHandler,
|
||||
currentWorkflow,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
// Handle validate tool calls
|
||||
if (toolCall.name === 'validate_workflow' && textEditorToolHandler && textEditorHandler) {
|
||||
const result = yield* this.validateToolHandler.execute({
|
||||
toolCallId: toolCall.id!,
|
||||
code: textEditorHandler.getWorkflowCode(),
|
||||
currentWorkflow,
|
||||
iteration,
|
||||
messages,
|
||||
warningTracker,
|
||||
});
|
||||
|
||||
return {
|
||||
workflow: result.workflow,
|
||||
workflowReady: result.workflowReady,
|
||||
parseDuration: result.parseDuration,
|
||||
validatePassed: result.workflowReady,
|
||||
};
|
||||
}
|
||||
|
||||
// Execute other tools
|
||||
yield* this.executeGeneralToolCall(toolCall, messages);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a general tool call (not text editor or validate)
|
||||
*/
|
||||
private async *executeGeneralToolCall(
|
||||
toolCall: ToolCall,
|
||||
messages: BaseMessage[],
|
||||
): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
const displayTitle = this.toolDisplayTitles?.get(toolCall.name);
|
||||
|
||||
// Stream tool progress
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'running',
|
||||
args: toolCall.args,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
|
||||
const tool = this.toolsMap.get(toolCall.name);
|
||||
if (!tool) {
|
||||
const errorMessage = `Tool '${toolCall.name}' not found`;
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id!,
|
||||
content: errorMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
// Yield error status to update UI (was missing - tool left in 'running' state)
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: unknown = await tool.invoke(toolCall.args);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
|
||||
// Add tool result to messages
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id!,
|
||||
content: resultStr,
|
||||
}),
|
||||
);
|
||||
|
||||
// Stream tool completion
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'completed',
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id!,
|
||||
content: `Error: ${errorMessage}`,
|
||||
}),
|
||||
);
|
||||
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a batch_str_replace tool call
|
||||
*/
|
||||
private async *executeBatchStrReplace(params: {
|
||||
toolCall: ToolCall;
|
||||
textEditorHandler: TextEditorHandler;
|
||||
messages: BaseMessage[];
|
||||
textEditorToolHandler?: TextEditorToolHandler;
|
||||
currentWorkflow?: WorkflowJSON;
|
||||
}): AsyncGenerator<StreamOutput, void, unknown> {
|
||||
const { toolCall, textEditorHandler, messages, textEditorToolHandler, currentWorkflow } =
|
||||
params;
|
||||
const displayTitle = 'Editing workflow';
|
||||
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'running',
|
||||
args: toolCall.args,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const replacements = parseReplacements(toolCall.args.replacements);
|
||||
const result = textEditorHandler.executeBatch(replacements);
|
||||
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id!,
|
||||
content,
|
||||
}),
|
||||
);
|
||||
|
||||
// Preview parse after successful batch edit for progressive canvas rendering
|
||||
if (typeof result === 'string' && textEditorToolHandler) {
|
||||
const preview = await textEditorToolHandler.tryParseForPreview(currentWorkflow);
|
||||
if (preview.chunk) {
|
||||
yield preview.chunk;
|
||||
}
|
||||
if (preview.parseError) {
|
||||
const lastMsg = messages[messages.length - 1] as ToolMessage;
|
||||
lastMsg.content = `${lastMsg.content as string}\n\nParse error: ${preview.parseError}`;
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'completed',
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCall.id!,
|
||||
content: `Error: ${errorMessage}`,
|
||||
}),
|
||||
);
|
||||
|
||||
yield {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
displayTitle,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Validate Tool Handler
|
||||
*
|
||||
* Handles the validate_workflow tool execution. Extracts the validation logic
|
||||
* from the code-builder-agent's executeValidateTool method.
|
||||
*/
|
||||
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ToolMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import type { StreamOutput, ToolProgressChunk, WorkflowUpdateChunk } from '../../types/streaming';
|
||||
import { FIX_VALIDATION_ERRORS_INSTRUCTION } from '../constants';
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
import type { ParseAndValidateResult } from '../types';
|
||||
import { formatWarnings } from '../utils/format-warnings';
|
||||
|
||||
/**
|
||||
* Parse and validate function type
|
||||
*/
|
||||
type ParseAndValidateFn = (
|
||||
code: string,
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
) => Promise<ParseAndValidateResult>;
|
||||
|
||||
/**
|
||||
* Get error context function type
|
||||
*/
|
||||
type GetErrorContextFn = (code: string, errorMessage: string) => string;
|
||||
|
||||
/**
|
||||
* Configuration for ValidateToolHandler
|
||||
*/
|
||||
export interface ValidateToolHandlerConfig {
|
||||
parseAndValidate: ParseAndValidateFn;
|
||||
getErrorContext: GetErrorContextFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for executing the validate tool
|
||||
*/
|
||||
export interface ValidateToolParams {
|
||||
toolCallId: string;
|
||||
code: string | null;
|
||||
currentWorkflow: WorkflowJSON | undefined;
|
||||
iteration: number;
|
||||
messages: BaseMessage[];
|
||||
warningTracker: WarningTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validate tool execution
|
||||
*/
|
||||
export interface ValidateToolResult {
|
||||
workflowReady: boolean;
|
||||
workflow?: WorkflowJSON;
|
||||
parseDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the validate_workflow tool execution.
|
||||
*
|
||||
* This handler:
|
||||
* 1. Validates workflow code using parseAndValidate
|
||||
* 2. Tracks warnings and filters duplicates
|
||||
* 3. Yields progress and workflow update chunks
|
||||
* 4. Returns whether the workflow is ready
|
||||
*/
|
||||
export class ValidateToolHandler {
|
||||
private parseAndValidate: ParseAndValidateFn;
|
||||
private getErrorContext: GetErrorContextFn;
|
||||
|
||||
constructor(config: ValidateToolHandlerConfig) {
|
||||
this.parseAndValidate = config.parseAndValidate;
|
||||
this.getErrorContext = config.getErrorContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the validate_workflow tool.
|
||||
*
|
||||
* @param params - Execution parameters
|
||||
* @yields StreamOutput chunks for tool progress and workflow updates
|
||||
* @returns ValidateToolResult with workflowReady status and optional workflow
|
||||
*/
|
||||
async *execute(
|
||||
params: ValidateToolParams,
|
||||
): AsyncGenerator<StreamOutput, ValidateToolResult, unknown> {
|
||||
const { toolCallId, code, currentWorkflow, messages, warningTracker } = params;
|
||||
|
||||
// Stream tool progress - running
|
||||
yield this.createToolProgressChunk('running', toolCallId);
|
||||
|
||||
// Check if code exists
|
||||
if (!code) {
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: 'Error: No workflow code to validate. Use text editor tools to add code first.',
|
||||
}),
|
||||
);
|
||||
yield this.createToolProgressChunk('completed', toolCallId);
|
||||
return { workflowReady: false };
|
||||
}
|
||||
|
||||
const parseStartTime = Date.now();
|
||||
try {
|
||||
const result = await this.parseAndValidate(code, currentWorkflow);
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
|
||||
// Handle warnings
|
||||
if (result.warnings.length > 0) {
|
||||
const newWarnings = warningTracker.filterNewWarnings(result.warnings);
|
||||
|
||||
if (newWarnings.length > 0) {
|
||||
// Track new warnings
|
||||
warningTracker.markAsSeen(newWarnings);
|
||||
|
||||
const warningText = formatWarnings(newWarnings, warningTracker);
|
||||
const errorContext = this.getErrorContext(code, newWarnings[0].message);
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: `Validation warnings:\n${warningText}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
}),
|
||||
);
|
||||
|
||||
// Stream partial workflow for progressive rendering
|
||||
yield this.createWorkflowUpdateChunk(result.workflow);
|
||||
yield this.createToolProgressChunk('completed', toolCallId);
|
||||
|
||||
return {
|
||||
workflowReady: false,
|
||||
workflow: result.workflow,
|
||||
parseDuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Validation passed
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content:
|
||||
'Validation passed. Workflow code is valid.\n\nIMPORTANT: Stop calling tools now to finalize the workflow.',
|
||||
}),
|
||||
);
|
||||
|
||||
// Stream workflow update
|
||||
yield this.createWorkflowUpdateChunk(result.workflow);
|
||||
yield this.createToolProgressChunk('completed', toolCallId);
|
||||
|
||||
return {
|
||||
workflowReady: true,
|
||||
workflow: result.workflow,
|
||||
parseDuration,
|
||||
};
|
||||
} catch (error) {
|
||||
const parseDuration = Date.now() - parseStartTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorContext = this.getErrorContext(code, errorMessage);
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content: `Parse error: ${errorMessage}\n\n${errorContext}\n\n${FIX_VALIDATION_ERRORS_INSTRUCTION}`,
|
||||
}),
|
||||
);
|
||||
|
||||
yield this.createToolProgressChunk('completed', toolCallId);
|
||||
|
||||
return {
|
||||
workflowReady: false,
|
||||
parseDuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tool progress chunk
|
||||
*/
|
||||
private createToolProgressChunk(
|
||||
status: 'running' | 'completed',
|
||||
toolCallId: string,
|
||||
): StreamOutput {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolName: 'validate_workflow',
|
||||
toolCallId,
|
||||
displayTitle: 'Validating workflow',
|
||||
status,
|
||||
} as ToolProgressChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow update chunk
|
||||
*/
|
||||
private createWorkflowUpdateChunk(workflow: WorkflowJSON): StreamOutput {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify(workflow, null, 2),
|
||||
} as WorkflowUpdateChunk,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Code Builder Module
|
||||
*
|
||||
* Public API for the code builder agent and related utilities.
|
||||
*/
|
||||
|
||||
// Agent
|
||||
export { CodeBuilderAgent } from './code-builder-agent';
|
||||
|
||||
// Types
|
||||
export type { CodeBuilderAgentConfig } from './types';
|
||||
|
||||
// Code Workflow Builder
|
||||
export { CodeWorkflowBuilder } from './code-workflow-builder';
|
||||
export type { CodeWorkflowBuilderConfig } from './code-workflow-builder';
|
||||
|
||||
// Session utilities
|
||||
export { generateCodeBuilderThreadId } from './utils/code-builder-session';
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import { buildCodeBuilderPrompt } from '../../code-builder/prompts/index';
|
||||
import type { PlanOutput } from '../../types/planning';
|
||||
|
||||
describe('buildCodeBuilderPrompt', () => {
|
||||
describe('extended thinking compatibility', () => {
|
||||
it('does not contain <thinking> tag instructions in system prompt', async () => {
|
||||
const prompt = buildCodeBuilderPrompt();
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const systemMessage = messages.find((m) => m._getType() === 'system');
|
||||
|
||||
// With native extended thinking enabled, the prompt should NOT reference
|
||||
// <thinking> XML tags which cause the model to emit reasoning in output tokens
|
||||
const content = Array.isArray(systemMessage?.content)
|
||||
? systemMessage.content.map((b) => ('text' in b ? b.text : '')).join('')
|
||||
: String(systemMessage?.content ?? '');
|
||||
|
||||
expect(content).not.toMatch(/<thinking>/);
|
||||
expect(content).not.toMatch(/`<thinking>`/);
|
||||
expect(content).not.toMatch(/Inside.*thinking.*tags/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preGeneratedCode option', () => {
|
||||
it('uses preGeneratedCode when provided instead of generating', async () => {
|
||||
const workflow: WorkflowJSON = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const customCode = `// Custom pre-generated code
|
||||
const start = trigger({ type: 'n8n-nodes-base.manualTrigger' });
|
||||
export default workflow('', 'Test').add(start);`;
|
||||
|
||||
const prompt = buildCodeBuilderPrompt(workflow, undefined, {
|
||||
preGeneratedCode: customCode,
|
||||
});
|
||||
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
// Should contain the custom code, not auto-generated
|
||||
expect(content).toContain('// Custom pre-generated code');
|
||||
});
|
||||
|
||||
it('does not contain approved_plan section when no planOutput provided', async () => {
|
||||
const prompt = buildCodeBuilderPrompt(undefined, undefined, {});
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
expect(content).not.toContain('<approved_plan>');
|
||||
});
|
||||
|
||||
it('falls back to generateWorkflowCode when preGeneratedCode not provided', async () => {
|
||||
const workflow: WorkflowJSON = {
|
||||
name: 'Fallback Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const prompt = buildCodeBuilderPrompt(workflow, undefined, {});
|
||||
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
// Should contain auto-generated code with workflow name
|
||||
expect(content).toContain("workflow('', 'Fallback Test')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow file section', () => {
|
||||
it('includes "No file exists yet" message when no workflow is provided', async () => {
|
||||
const prompt = buildCodeBuilderPrompt();
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
expect(content).toContain('<workflow_file path="/workflow.js">');
|
||||
expect(content).toContain('No file exists yet');
|
||||
expect(content).toContain('create');
|
||||
expect(content).toContain('</workflow_file>');
|
||||
});
|
||||
|
||||
it('does not include "No file exists yet" when workflow is provided', async () => {
|
||||
const workflow: WorkflowJSON = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const prompt = buildCodeBuilderPrompt(workflow);
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
expect(content).toContain('<workflow_file path="/workflow.js">');
|
||||
expect(content).not.toContain('No file exists yet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planOutput option', () => {
|
||||
const mockPlan: PlanOutput = {
|
||||
summary: 'Fetch weather and send Slack alert',
|
||||
trigger: 'Runs every morning at 7 AM',
|
||||
steps: [
|
||||
{ description: 'Fetch weather forecast', suggestedNodes: ['n8n-nodes-base.httpRequest'] },
|
||||
{ description: 'Check if rain is predicted' },
|
||||
{ description: 'Send Slack notification', suggestedNodes: ['n8n-nodes-base.slack'] },
|
||||
],
|
||||
additionalSpecs: ['Use metric units for temperature'],
|
||||
};
|
||||
|
||||
it('includes approved_plan section in user message when planOutput is provided', async () => {
|
||||
const prompt = buildCodeBuilderPrompt(undefined, undefined, {
|
||||
planOutput: mockPlan,
|
||||
});
|
||||
|
||||
const messages = await prompt.formatMessages({ userMessage: 'Build weather workflow' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const content = humanMessage?.content as string;
|
||||
|
||||
expect(content).toContain('<approved_plan>');
|
||||
expect(content).toContain('</approved_plan>');
|
||||
expect(content).toContain('Fetch weather and send Slack alert');
|
||||
expect(content).toContain('Runs every morning at 7 AM');
|
||||
expect(content).toContain('Fetch weather forecast');
|
||||
expect(content).toContain('n8n-nodes-base.httpRequest');
|
||||
expect(content).toContain('Use metric units for temperature');
|
||||
});
|
||||
|
||||
it('includes plan_mode_instructions in system prompt when planOutput is provided', async () => {
|
||||
const prompt = buildCodeBuilderPrompt(undefined, undefined, {
|
||||
planOutput: mockPlan,
|
||||
});
|
||||
|
||||
const messages = await prompt.formatMessages({ userMessage: 'Build weather workflow' });
|
||||
const systemMessage = messages.find((m) => m._getType() === 'system');
|
||||
const content = Array.isArray(systemMessage?.content)
|
||||
? systemMessage.content.map((b) => ('text' in b ? b.text : '')).join('')
|
||||
: String(systemMessage?.content ?? '');
|
||||
|
||||
expect(content).toContain('<plan_mode_instructions>');
|
||||
expect(content).toContain('</plan_mode_instructions>');
|
||||
});
|
||||
|
||||
it('does not include plan sections when planOutput is not provided', async () => {
|
||||
const prompt = buildCodeBuilderPrompt();
|
||||
|
||||
const messages = await prompt.formatMessages({ userMessage: 'test' });
|
||||
const humanMessage = messages.find((m) => m._getType() === 'human');
|
||||
const humanContent = humanMessage?.content as string;
|
||||
|
||||
const systemMessage = messages.find((m) => m._getType() === 'system');
|
||||
const systemContent = Array.isArray(systemMessage?.content)
|
||||
? systemMessage.content.map((b) => ('text' in b ? b.text : '')).join('')
|
||||
: String(systemMessage?.content ?? '');
|
||||
|
||||
expect(humanContent).not.toContain('<approved_plan>');
|
||||
expect(systemContent).not.toContain('<plan_mode_instructions>');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* Code Builder Agent Prompt
|
||||
*
|
||||
* System prompt for the unified code builder agent that generates complete workflows
|
||||
* in JavaScript SDK format. Combines planning and code generation in a single pass.
|
||||
*/
|
||||
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import { generateWorkflowCode } from '@n8n/workflow-sdk';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type { IRunExecutionData, NodeExecutionSchema } from 'n8n-workflow';
|
||||
|
||||
import type { PlanOutput } from '../../types/planning';
|
||||
import { formatPlanAsText } from '../../utils/plan-helpers';
|
||||
import type { ExpressionValue } from '../../workflow-builder-agent';
|
||||
import { formatCodeWithLineNumbers } from '../handlers/text-editor-handler';
|
||||
import { SDK_IMPORT_STATEMENT } from '../utils/extract-code';
|
||||
|
||||
/**
|
||||
* Escape curly brackets for LangChain prompt templates
|
||||
*/
|
||||
function escapeCurlyBrackets(text: string): string {
|
||||
return text.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expression context reference - documents variables available inside expr()
|
||||
*/
|
||||
const EXPRESSION_REFERENCE = `Available variables inside \`expr('{{{{ ... }}}}')\`:
|
||||
|
||||
- \`$json\` — current item's JSON data from the immediate predecessor node
|
||||
- \`$('NodeName').item.json\` — access any node's output by name
|
||||
- \`$input.first()\` — first item from immediate predecessor
|
||||
- \`$input.all()\` — all items from immediate predecessor
|
||||
- \`$input.item\` — current item being processed
|
||||
- \`$binary\` — binary data of current item from immediate predecessor
|
||||
- \`$now\` — current date/time (Luxon DateTime). Example: \`$now.toISO()\`
|
||||
- \`$today\` — start of today (Luxon DateTime). Example: \`$today.plus(1, 'days')\`
|
||||
- \`$itemIndex\` — index of current item being processed
|
||||
- \`$runIndex\` — current run index
|
||||
- \`$execution.id\` — unique execution ID
|
||||
- \`$execution.mode\` — 'test' or 'production'
|
||||
- \`$workflow.id\` — workflow ID
|
||||
- \`$workflow.name\` — workflow name`;
|
||||
|
||||
/**
|
||||
* Additional SDK functions not covered by main workflow patterns
|
||||
*/
|
||||
const ADDITIONAL_FUNCTIONS = `Additional SDK functions:
|
||||
|
||||
- \`placeholder('hint')\` — marks a parameter value for user input. Use directly as the parameter value — never wrap in \`expr()\`, objects, or arrays.
|
||||
Example: \`parameters: {{ url: placeholder('Your API URL (e.g. https://api.example.com/v1)') }}\`
|
||||
|
||||
- \`sticky('content', nodes?, config?)\` — creates a sticky note on the canvas.
|
||||
Example: \`sticky('## Data Processing', [httpNode, setNode], {{ color: 2 }})\`
|
||||
|
||||
- \`.output(n)\` — selects a specific output index for multi-output nodes. IF and Switch have dedicated methods (\`onTrue/onFalse\`, \`onCase\`), but \`.output(n)\` works as a generic alternative.
|
||||
Example: \`classifier.output(1).to(categoryB)\`
|
||||
|
||||
- \`.onError(handler)\` — connects a node's error output to a handler node. Requires \`onError: 'continueErrorOutput'\` in the node config.
|
||||
Example: \`httpNode.onError(errorHandler)\` (with \`config: {{ onError: 'continueErrorOutput' }}\`)
|
||||
|
||||
- Additional subnode factories (all follow the same pattern as \`languageModel()\` and \`tool()\`):
|
||||
\`memory()\`, \`outputParser()\`, \`embeddings()\`, \`vectorStore()\`, \`retriever()\`, \`documentLoader()\`, \`textSplitter()\``;
|
||||
|
||||
/**
|
||||
* Role and capabilities of the agent
|
||||
*/
|
||||
const ROLE =
|
||||
'You are an expert n8n workflow builder. Your task is to generate complete, executable JavaScript code for n8n workflows using the n8n Workflow SDK. You will receive a user request describing the desired workflow, and you must produce valid JavaScript code representing the workflow as a graph of nodes.';
|
||||
|
||||
/**
|
||||
* Response style guidance - positive guardrails for concise communication
|
||||
*/
|
||||
const RESPONSE_STYLE = `**Be extremely concise in your visible responses.** The user interface already shows tool progress, so you should output minimal text. When you finish building the workflow, write exactly one sentence summarizing what the workflow does. Nothing more.
|
||||
|
||||
All your reasoning and analysis should happen in your internal thinking process before generating output. Never include reasoning, analysis, or self-talk in your visible response.`;
|
||||
|
||||
/**
|
||||
* Workflow rules - strict constraints for code generation
|
||||
*/
|
||||
const WORKFLOW_RULES = `Follow these rules strictly when generating workflows:
|
||||
|
||||
1. **Always use newCredential() for authentication**
|
||||
- When a node needs credentials, always use \`newCredential('Name')\` in the credentials config
|
||||
- NEVER use placeholder strings, fake API keys, or hardcoded auth values
|
||||
- Example: \`credentials: {{ slackApi: newCredential('Slack Bot') }}\`
|
||||
- The credential type must match what the node expects`;
|
||||
|
||||
/**
|
||||
* SDK import line escaped for use in LangChain prompt templates
|
||||
*/
|
||||
const SDK_IMPORT_ESCAPED = escapeCurlyBrackets(SDK_IMPORT_STATEMENT);
|
||||
|
||||
/**
|
||||
* Workflow patterns - condensed examples
|
||||
*/
|
||||
const WORKFLOW_PATTERNS = `<linear_chain>
|
||||
\`\`\`javascript
|
||||
${SDK_IMPORT_ESCAPED}
|
||||
|
||||
// 1. Define all nodes first
|
||||
const startTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {{ name: 'Start', position: [240, 300] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const fetchData = node({{
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.3,
|
||||
config: {{ name: 'Fetch Data', parameters: {{ method: 'GET', url: '...' }}, position: [540, 300] }},
|
||||
output: [{{ id: 1, title: 'Item 1' }}]
|
||||
}});
|
||||
|
||||
const processData = node({{
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {{ name: 'Process Data', parameters: {{}}, position: [840, 300] }},
|
||||
output: [{{ id: 1, title: 'Item 1', processed: true }}]
|
||||
}});
|
||||
|
||||
// 2. Compose workflow
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(fetchData)
|
||||
.to(processData);
|
||||
\`\`\`
|
||||
|
||||
</linear_chain>
|
||||
|
||||
<independent_sources>
|
||||
When nodes return more than 1 item, chaining causes item multiplication: if Source A returns N items, a chained Source B runs N times instead of once.
|
||||
|
||||
Fix with \`executeOnce: true\` (simplest) or parallel branches + Merge (when combining results):
|
||||
|
||||
\`\`\`javascript
|
||||
// sourceA outputs 10 items. sourceB outputs 10 items.
|
||||
// sourceB runs once per item from sourceA.
|
||||
// WRONG - processResults runs 100 times
|
||||
// startTrigger.to(sourceA.to(sourceB.to(processResults)))
|
||||
|
||||
// FIX 1 - executeOnce: sourceB runs once regardless of input items
|
||||
const sourceB = node({{ ..., config: {{ ..., executeOnce: true }} }});
|
||||
startTrigger.to(sourceA.to(sourceB.to(processResults)));
|
||||
|
||||
// FIX 2 - parallel branches + Merge (combine by position)
|
||||
// Pairs items by index, merging fields from both inputs into one item.
|
||||
// @example input0: [{{ a: 1 }}, {{ a: 2 }}] input1: [{{ b: 10, c: 'x' }}, {{ b: 20 }}]
|
||||
// output: [{{ a: 1, b: 10, c: 'x' }}, {{ a: 2, b: 20, c: undefined }}]
|
||||
const combineResults = merge({{
|
||||
version: 3.2,
|
||||
config: {{ name: 'Combine Results', parameters: {{ mode: 'combine', combineBy: 'combineByPosition' }} }}
|
||||
}});
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(sourceA.to(combineResults.input(0)))
|
||||
.add(startTrigger)
|
||||
.to(sourceB.to(combineResults.input(1)))
|
||||
.add(combineResults)
|
||||
.to(processResults);
|
||||
|
||||
// FIX 3 - parallel branches + Merge (append)
|
||||
// Concatenates all items from all inputs into one list sequentially.
|
||||
// @example input0: [{{ a: 1 }}, {{ a: 2 }}] input1: [{{ b: 10 }}]
|
||||
// output: [{{ a: 1 }}, {{ a: 2 }}, {{ b: 10 }}]
|
||||
const allResults = merge({{
|
||||
version: 3.2,
|
||||
config: {{ name: 'All Results', parameters: {{ mode: 'append' }} }}
|
||||
}});
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(sourceA.to(allResults.input(0)))
|
||||
.add(startTrigger)
|
||||
.to(sourceB.to(allResults.input(1)))
|
||||
.add(allResults)
|
||||
.to(processResults);
|
||||
\`\`\`
|
||||
|
||||
</independent_sources>
|
||||
|
||||
<conditional_branching>
|
||||
|
||||
**CRITICAL:** Each branch defines a COMPLETE processing path. Chain multiple steps INSIDE the branch using .to().
|
||||
|
||||
\`\`\`javascript
|
||||
// Assume other nodes are declared
|
||||
const checkValid = ifElse({{ version: 2.2, config: {{ name: 'Check Valid', parameters: {{...}} }} }});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(checkValid
|
||||
.onTrue(formatData.to(enrichData.to(saveToDb))) // Chain 3 nodes on true branch
|
||||
.onFalse(logError));
|
||||
\`\`\`
|
||||
|
||||
</conditional_branching>
|
||||
|
||||
<multi_way_routing>
|
||||
|
||||
\`\`\`javascript
|
||||
// Assume other nodes are declared
|
||||
const routeByPriority = switchCase({{ version: 3.2, config: {{ name: 'Route by Priority', parameters: {{...}} }} }});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(routeByPriority
|
||||
.onCase(0, processUrgent.to(notifyTeam.to(escalate))) // Chain of 3 nodes
|
||||
.onCase(1, processNormal)
|
||||
.onCase(2, archive));
|
||||
\`\`\`
|
||||
|
||||
</multi_way_routing>
|
||||
|
||||
<parallel_execution>
|
||||
\`\`\`javascript
|
||||
// First declare the Merge node using merge()
|
||||
const combineResults = merge({{
|
||||
version: 3.2,
|
||||
config: {{
|
||||
name: 'Combine Results',
|
||||
parameters: {{ mode: 'combine' }},
|
||||
position: [840, 300]
|
||||
}}
|
||||
}});
|
||||
|
||||
// Declare branch nodes
|
||||
const branch1 = node({{ type: 'n8n-nodes-base.httpRequest', ... }});
|
||||
const branch2 = node({{ type: 'n8n-nodes-base.httpRequest', ... }});
|
||||
const processResults = node({{ type: 'n8n-nodes-base.set', ... }});
|
||||
|
||||
// Connect branches to specific merge inputs using .input(n)
|
||||
export default workflow('id', 'name')
|
||||
.add(trigger({{ ... }}))
|
||||
.to(branch1.to(combineResults.input(0))) // Connect to input 0
|
||||
.add(trigger({{ ... }}))
|
||||
.to(branch2.to(combineResults.input(1))) // Connect to input 1
|
||||
.add(combineResults)
|
||||
.to(processResults); // Process merged results
|
||||
\`\`\`
|
||||
|
||||
</parallel_execution>
|
||||
|
||||
<batch_processing>
|
||||
\`\`\`javascript
|
||||
const startTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {{ name: 'Start', position: [240, 300] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const fetchRecords = node({{
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.3,
|
||||
config: {{ name: 'Fetch Records', parameters: {{ method: 'GET', url: '...' }}, position: [540, 300] }},
|
||||
output: [{{ id: 1 }}, {{ id: 2 }}, {{ id: 3 }}]
|
||||
}});
|
||||
|
||||
const finalizeResults = node({{
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {{ name: 'Finalize', parameters: {{}}, position: [1140, 200] }},
|
||||
output: [{{ summary: 'Processed 3 records' }}]
|
||||
}});
|
||||
|
||||
const processRecord = node({{
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.3,
|
||||
config: {{ name: 'Process Record', parameters: {{ method: 'POST', url: '...' }}, position: [1140, 400] }},
|
||||
output: [{{ id: 1, status: 'processed' }}]
|
||||
}});
|
||||
|
||||
const sibNode = splitInBatches({{ version: 3, config: {{ name: 'Batch Process', parameters: {{ batchSize: 10 }}, position: [840, 300] }} }});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(startTrigger)
|
||||
.to(fetchRecords)
|
||||
.to(sibNode
|
||||
.onDone(finalizeResults)
|
||||
.onEachBatch(processRecord.to(nextBatch(sibNode)))
|
||||
);
|
||||
\`\`\`
|
||||
|
||||
</batch_processing>
|
||||
|
||||
<multiple_triggers>
|
||||
\`\`\`javascript
|
||||
const webhookTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
version: 2.1,
|
||||
config: {{ name: 'Webhook', position: [240, 200] }},
|
||||
output: [{{ body: {{ data: 'webhook payload' }} }}]
|
||||
}});
|
||||
|
||||
const processWebhook = node({{
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {{ name: 'Process Webhook', parameters: {{}}, position: [540, 200] }},
|
||||
output: [{{ data: 'webhook payload', processed: true }}]
|
||||
}});
|
||||
|
||||
const scheduleTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
version: 1.3,
|
||||
config: {{ name: 'Daily Schedule', parameters: {{}}, position: [240, 500] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const processSchedule = node({{
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {{ name: 'Process Schedule', parameters: {{}}, position: [540, 500] }},
|
||||
output: [{{ scheduled: true }}]
|
||||
}});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(webhookTrigger)
|
||||
.to(processWebhook)
|
||||
.add(scheduleTrigger)
|
||||
.to(processSchedule);
|
||||
\`\`\`
|
||||
|
||||
</multiple_triggers>
|
||||
|
||||
<fan_in>
|
||||
\`\`\`javascript
|
||||
// Each trigger's execution runs in COMPLETE ISOLATION.
|
||||
// Different branches have no effect on each other.
|
||||
// Never duplicate chains for "isolation" - it's already guaranteed.
|
||||
|
||||
const webhookTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
version: 2.1,
|
||||
config: {{ name: 'Webhook Trigger', position: [240, 200] }},
|
||||
output: [{{ source: 'webhook' }}]
|
||||
}});
|
||||
|
||||
const scheduleTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
version: 1.3,
|
||||
config: {{ name: 'Daily Schedule', position: [240, 500] }},
|
||||
output: [{{ source: 'schedule' }}]
|
||||
}});
|
||||
|
||||
const processData = node({{
|
||||
type: 'n8n-nodes-base.set',
|
||||
version: 3.4,
|
||||
config: {{ name: 'Process Data', parameters: {{}}, position: [540, 350] }},
|
||||
output: [{{ processed: true }}]
|
||||
}});
|
||||
|
||||
const sendNotification = node({{
|
||||
type: 'n8n-nodes-base.slack',
|
||||
version: 2.3,
|
||||
config: {{ name: 'Notify Slack', parameters: {{}}, position: [840, 350] }},
|
||||
output: [{{ ok: true }}]
|
||||
}});
|
||||
|
||||
export default workflow('id', 'name')
|
||||
.add(webhookTrigger)
|
||||
.to(processData)
|
||||
.to(sendNotification)
|
||||
.add(scheduleTrigger)
|
||||
.to(processData);
|
||||
\`\`\`
|
||||
|
||||
</fan_in>
|
||||
|
||||
<ai_agent_basic>
|
||||
\`\`\`javascript
|
||||
const openAiModel = languageModel({{
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {{ name: 'OpenAI Model', parameters: {{}}, position: [540, 500] }}
|
||||
}});
|
||||
|
||||
const startTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {{ name: 'Start', position: [240, 300] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const aiAgent = node({{
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {{
|
||||
name: 'AI Assistant',
|
||||
parameters: {{ promptType: 'define', text: 'You are a helpful assistant' }},
|
||||
subnodes: {{ model: openAiModel }},
|
||||
position: [540, 300]
|
||||
}},
|
||||
output: [{{ output: 'AI response text' }}]
|
||||
}});
|
||||
|
||||
export default workflow('ai-assistant', 'AI Assistant')
|
||||
.add(startTrigger)
|
||||
.to(aiAgent);
|
||||
\`\`\`
|
||||
|
||||
</ai_agent_basic>
|
||||
|
||||
<ai_agent_with_tools>
|
||||
\`\`\`javascript
|
||||
const openAiModel = languageModel({{
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {{
|
||||
name: 'OpenAI Model',
|
||||
parameters: {{}},
|
||||
credentials: {{ openAiApi: newCredential('OpenAI') }},
|
||||
position: [540, 500]
|
||||
}}
|
||||
}});
|
||||
|
||||
const calculatorTool = tool({{
|
||||
type: '@n8n/n8n-nodes-langchain.toolCalculator',
|
||||
version: 1,
|
||||
config: {{ name: 'Calculator', parameters: {{}}, position: [700, 500] }}
|
||||
}});
|
||||
|
||||
const startTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {{ name: 'Start', position: [240, 300] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const aiAgent = node({{
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {{
|
||||
name: 'Math Agent',
|
||||
parameters: {{ promptType: 'define', text: 'You can use tools to help users' }},
|
||||
subnodes: {{ model: openAiModel, tools: [calculatorTool] }},
|
||||
position: [540, 300]
|
||||
}},
|
||||
output: [{{ output: '42' }}]
|
||||
}});
|
||||
|
||||
export default workflow('ai-calculator', 'AI Calculator')
|
||||
.add(startTrigger)
|
||||
.to(aiAgent);
|
||||
\`\`\`
|
||||
|
||||
</ai_agent_with_tools>
|
||||
|
||||
<ai_agent_with_from_ai>
|
||||
\`\`\`javascript
|
||||
const openAiModel = languageModel({{
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
version: 1.3,
|
||||
config: {{
|
||||
name: 'OpenAI Model',
|
||||
parameters: {{}},
|
||||
credentials: {{ openAiApi: newCredential('OpenAI') }},
|
||||
position: [540, 500]
|
||||
}}
|
||||
}});
|
||||
|
||||
const gmailTool = tool({{
|
||||
type: 'n8n-nodes-base.gmailTool',
|
||||
version: 1,
|
||||
config: {{
|
||||
name: 'Gmail Tool',
|
||||
parameters: {{
|
||||
sendTo: fromAi('recipient', 'Email address'),
|
||||
subject: fromAi('subject', 'Email subject'),
|
||||
message: fromAi('body', 'Email content')
|
||||
}},
|
||||
credentials: {{ gmailOAuth2: newCredential('Gmail') }},
|
||||
position: [700, 500]
|
||||
}}
|
||||
}});
|
||||
|
||||
const startTrigger = trigger({{
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {{ name: 'Start', position: [240, 300] }},
|
||||
output: [{{}}]
|
||||
}});
|
||||
|
||||
const aiAgent = node({{
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {{
|
||||
name: 'Email Agent',
|
||||
parameters: {{ promptType: 'define', text: 'You can send emails' }},
|
||||
subnodes: {{ model: openAiModel, tools: [gmailTool] }},
|
||||
position: [540, 300]
|
||||
}},
|
||||
output: [{{ output: 'Email sent successfully' }}]
|
||||
}});
|
||||
|
||||
export default workflow('ai-email', 'AI Email Sender')
|
||||
.add(startTrigger)
|
||||
.to(aiAgent);
|
||||
\`\`\`
|
||||
</ai_agent_with_from_ai>
|
||||
|
||||
<ai_agent_with_structured_output>
|
||||
\`\`\`javascript
|
||||
const structuredParser = outputParser({{
|
||||
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
version: 1.3,
|
||||
config: {{
|
||||
name: 'Structured Output Parser',
|
||||
parameters: {{
|
||||
schemaType: 'fromJson',
|
||||
jsonSchemaExample: '{{{{ "sentiment": "positive", "confidence": 0.95, "summary": "brief summary" }}}}'
|
||||
}},
|
||||
position: [700, 500]
|
||||
}}
|
||||
}});
|
||||
|
||||
const aiAgent = node({{
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
version: 3.1,
|
||||
config: {{
|
||||
name: 'Sentiment Analyzer',
|
||||
parameters: {{ promptType: 'define', text: 'Analyze the sentiment of the input text', hasOutputParser: true }},
|
||||
subnodes: {{ model: openAiModel, outputParser: structuredParser }},
|
||||
position: [540, 300]
|
||||
}},
|
||||
output: [{{ sentiment: 'positive', confidence: 0.95, summary: 'The text expresses satisfaction' }}]
|
||||
}});
|
||||
|
||||
export default workflow('ai-sentiment', 'AI Sentiment Analyzer')
|
||||
.add(startTrigger)
|
||||
.to(aiAgent);
|
||||
\`\`\`
|
||||
</ai_agent_with_structured_output>`;
|
||||
|
||||
/**
|
||||
* Mandatory workflow for tool usage
|
||||
*/
|
||||
const MANDATORY_WORKFLOW = `**You MUST follow these steps in order. Do NOT produce visible output until the final step — only tool calls. Use the \`think\` tool in-between steps (after first step) when you need to reason about results.**
|
||||
|
||||
<step_1_analyze_user_request>
|
||||
|
||||
Analyze the user request internally. Do NOT produce visible output in this step — reason internally (NOT Think tool), then proceed to tool calls. Be concise.
|
||||
|
||||
1. **Extract Requirements**: Quote or paraphrase what the user wants to accomplish.
|
||||
|
||||
2. **Identify All Relevant Workflow Technique Categories that might be relevant**:
|
||||
- chatbot: Receiving chat messages and replying (built-in chat, Telegram, Slack, etc.)
|
||||
- notification: Sending alerts or updates via email, chat, SMS when events occur
|
||||
- scheduling: Running actions at specific times or intervals
|
||||
- data_transformation: Cleaning, formatting, or restructuring data
|
||||
- data_persistence: Storing, updating, or retrieving records from persistent storage
|
||||
- data_extraction: Pulling specific information from structured or unstructured inputs
|
||||
- document_processing: Taking action on content within files (PDFs, Word docs, images)
|
||||
- form_input: Gathering data from users via forms
|
||||
- content_generation: Creating text, images, audio, or video
|
||||
- triage: Classifying data for routing or prioritization
|
||||
- scraping_and_research: Collecting current/live information from external sources. ALSO applies when the workflow analyzes, monitors, tracks, or reports on real-world entities (stocks, weather, news, prices, people, companies) — the AI model alone cannot provide up-to-date data
|
||||
|
||||
3. **Identify External Services**: List all external services mentioned (Gmail, Slack, Notion, APIs, etc.)
|
||||
- Do NOT assume you know the node names yet
|
||||
- Just identify what services need to be connected
|
||||
|
||||
4. **Identify Workflow Concepts**: What patterns are needed?
|
||||
- Trigger type — every workflow must start with one (\`manualTrigger\` for testing, \`scheduleTrigger\` for recurring, \`webhook\` for external)
|
||||
- Branching/routing (if/else, switch, merge)
|
||||
- Loops (batch processing)
|
||||
- Data transformation needs
|
||||
|
||||
</step_1_analyze_user_request>
|
||||
|
||||
<step_2_search_for_nodes>
|
||||
|
||||
<step_2a_get_suggested_nodes>
|
||||
|
||||
Do NOT produce visible output — only the tool call. Call \`get_suggested_nodes\` with the workflow technique categories identified in Step 1:
|
||||
|
||||
\`\`\`
|
||||
get_suggested_nodes({{ categories: ["chatbot", "notification"] }})
|
||||
\`\`\`
|
||||
|
||||
This returns curated node recommendations with pattern hints and configuration guidance.
|
||||
|
||||
</step_2a_get_suggested_nodes>
|
||||
|
||||
<step_2b_search_for_nodes>
|
||||
|
||||
Do NOT produce visible output — only the tool call. Be EXTREMELY concise. Call \`search_nodes\` to find specific nodes for services identified in Step 1 and ALL node types you plan to use:
|
||||
|
||||
\`\`\`
|
||||
search_nodes({{ queries: ["gmail", "slack", "schedule trigger", "set", ...] }})
|
||||
\`\`\`
|
||||
|
||||
Search for:
|
||||
- External services (Gmail, Slack, etc.)
|
||||
- Workflow concepts (schedule, webhook, etc.)
|
||||
- **Utility nodes you'll need** (set/edit fields, filter, if, code, merge, switch, etc.)
|
||||
- AI-related terms if needed
|
||||
- **Nodes from suggested results you plan to use**
|
||||
|
||||
</step_2b_search_for_nodes>
|
||||
|
||||
<step_2c_review_search_results>
|
||||
|
||||
Use the \`think\` tool to review all results. Do NOT produce visible output in this step. Be EXTREMELY concise.
|
||||
|
||||
For each service/concept searched, list the matching node(s) found:
|
||||
- Note which nodes have [TRIGGER] tags for trigger nodes
|
||||
- Note discriminator requirements (resource/operation or mode) for each node
|
||||
- Note [RELATED] nodes that might be useful
|
||||
- Note @relatedNodes with relationHints for complementary nodes
|
||||
- **Pay special attention to @builderHint and @example annotations** — write these out as they are guides specifically meant to help you choose the right node configurations
|
||||
- Review patternHints and notes from get_suggested_nodes. If multiple categories were returned, focus on the most relevant patternHint for the user's core request — don't try to follow all of them
|
||||
- It's OK for this section to be quite long if many nodes were found
|
||||
|
||||
If you have everything you need to build a workflow, continue to step 3, planning the workflow design.
|
||||
|
||||
</step_2c_review_search_results>
|
||||
|
||||
</step_2_search_for_nodes>
|
||||
|
||||
<step_3_plan_workflow_design>
|
||||
|
||||
Use the \`think\` tool to make design decisions based on the reviewed results. Do NOT produce visible output in this step.
|
||||
|
||||
1. **Select Nodes**: Based on search results AND suggested nodes, choose specific nodes:
|
||||
- Use dedicated integration nodes when available (from search)
|
||||
- Only use HTTP Request if no dedicated node was found
|
||||
- Note discriminators needed for each node
|
||||
- Use the most relevant patternHint as a starting template for your workflow structure. When multiple patternHints were returned, pick the one that best matches the user's core goal — don't merge all hints into one structure
|
||||
- Review node notes from all categories for recommended additions the user may not have explicitly requested (e.g., a storage step, memory for agents)
|
||||
- **If you identified \`scraping_and_research\` in Step 1, you MUST include a data-fetching node or tool** (e.g., SerpApi tool, Perplexity, HTTP Request). Do not rely on the AI model's training data for real-world information — commit to the data source you identified earlier
|
||||
|
||||
2. **Map Node Connections**:
|
||||
- Is this linear, branching, parallel, or looped? Or merge to combine parallel branches?
|
||||
- **Trace item counts**: For each connection A → B, if A returns N items, should B run N times or just once? If B doesn't need A's items (e.g., it fetches from an independent source), either set \`executeOnce: true\` on B or use parallel branches + Merge to combine results.
|
||||
- Which nodes connect to which?
|
||||
- Draw out the flow in text form (e.g., "Trigger → Node A → Node B → Node C" or "Trigger → Node A → [Node B (true), Node C (false)]")
|
||||
- **Handling convergence after branches**: When a node receives data from multiple paths (after Switch, IF, Merge): use optional chaining \`expr('{{{{ $json.data?.approved ?? $json.status }}}}')\`, reference a node that ALWAYS runs \`expr("{{{{ $('Webhook').item.json.field }}}}")\`, or normalize data before convergence with Set nodes
|
||||
|
||||
3. **Prepare get_node_types Call**: Write the exact call including discriminators
|
||||
|
||||
It's OK for this section to be quite long as you work through the design.
|
||||
**Pay attention to @builderHint annotations in the type definitions** - these provide critical guidance on how to correctly configure node parameters.
|
||||
|
||||
</step_3_plan_workflow_design>
|
||||
|
||||
<step_4_get_node_type_definitions>
|
||||
|
||||
Do NOT produce visible output — only the tool call.
|
||||
|
||||
**MANDATORY:** Call \`get_node_types\` with ALL nodes you selected.
|
||||
|
||||
\`\`\`
|
||||
get_node_types({{ nodeIds: ["n8n-nodes-base.manualTrigger", {{ nodeId: "n8n-nodes-base.gmail", resource: "message", operation: "send" }}, ...] }})
|
||||
\`\`\`
|
||||
|
||||
Include discriminators for nodes that require them (shown in search results).
|
||||
|
||||
**DO NOT skip this step!** Guessing parameter names or versions creates invalid workflows.
|
||||
|
||||
**Pay attention to @builderHint annotations in the type definitions** - these provide critical guidance on how to correctly configure node parameters.
|
||||
|
||||
</step_4_get_node_type_definitions>
|
||||
|
||||
<step_5_create_or_edit_workflow>
|
||||
|
||||
Do NOT produce visible output — only the tool call to edit code.
|
||||
|
||||
Edit \`/workflow.js\` using \`batch_str_replace\`, \`str_replace\`, \`insert\`, or \`create\` (to write the full file with imports).
|
||||
|
||||
Rules:
|
||||
- When making multiple edits, prefer \`batch_str_replace\` to apply all changes atomically in one call.
|
||||
- Use exact parameter names and structures from the type definitions.
|
||||
- Use unique variable names — never reuse builder function names (e.g. \`node\`, \`trigger\`) as variable names
|
||||
- Use descriptive node names (Good: "Fetch Weather Data", "Check Temperature"; Bad: "HTTP Request", "Set", "If")
|
||||
- Credentials: \`credentials: {{ slackApi: newCredential('Slack Bot') }}\` — type must match what the node expects
|
||||
- Expressions: use \`expr()\` for any \`{{{{ }}}}\` syntax — always use single or double quotes, NOT backtick template literals
|
||||
- e.g. \`expr('Hello {{{{ $json.name }}}}')\` or \`expr("{{{{ $('Node').item.json.field }}}}")\`
|
||||
- For multiline expressions, use string concatenation: \`expr('Line 1\\n' + 'Line 2 {{{{ $json.value }}}}')\`
|
||||
- Placeholders: use \`placeholder('hint')\` directly as the parameter value, not inside \`expr()\`, objects, or arrays, etc.
|
||||
- Every node MUST have an \`output\` property with sample data — following nodes depend on it for expressions
|
||||
- String quoting: When a string value contains an apostrophe, use double quotes for that string.
|
||||
Example: \`output: [{{ text: "I've arrived" }}]\`
|
||||
- Do NOT add or edit comments. Comments are ignored and not shared with user. Use sticky(...) to provide guidance.
|
||||
|
||||
</step_5_create_or_edit_workflow>
|
||||
|
||||
<step_6_validate_workflow>
|
||||
|
||||
Do NOT produce visible output — only the tool call.
|
||||
|
||||
Call \`validate_workflow\` to check your code for errors before finalizing:
|
||||
|
||||
\`\`\`
|
||||
validate_workflow({{ path: "/workflow.js" }})
|
||||
\`\`\`
|
||||
|
||||
If errors are reported, fix ALL relevant issues using \`batch_str_replace\` (preferred for multiple fixes) or individual str_replace/insert calls, then call \`validate_workflow\` again. Do not call validate_workflow after each individual fix — batch all fixes first, then validate once. Focus on warnings relevant to your changes and last user request.
|
||||
|
||||
</step_6_validate_workflow>
|
||||
|
||||
<step_7_finalize>
|
||||
|
||||
When validation passes, stop calling tools.
|
||||
</step_7_finalize>`;
|
||||
|
||||
/**
|
||||
* History context for multi-turn conversations
|
||||
*/
|
||||
export interface HistoryContext {
|
||||
/** Previous user messages in this session */
|
||||
userMessages: string[];
|
||||
/** Compacted summary of older conversations (if any) */
|
||||
previousSummary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for building the code builder prompt
|
||||
*/
|
||||
export interface BuildCodeBuilderPromptOptions {
|
||||
/** Enable text editor mode */
|
||||
enableTextEditor?: boolean;
|
||||
/** Node output schemas from previous execution */
|
||||
executionSchema?: NodeExecutionSchema[];
|
||||
/** Execution result data for status and error info */
|
||||
executionData?: IRunExecutionData['resultData'];
|
||||
/** Expression values resolved from previous execution */
|
||||
expressionValues?: Record<string, ExpressionValue[]>;
|
||||
/** Pre-generated workflow code (used to ensure text editor and prompt have same content) */
|
||||
preGeneratedCode?: string;
|
||||
/** Whether execution schema values were excluded (redacted) */
|
||||
valuesExcluded?: boolean;
|
||||
/** Node names whose output schema was derived from pin data */
|
||||
pinnedNodes?: string[];
|
||||
/** Approved plan from planning phase */
|
||||
planOutput?: PlanOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete system prompt for the code builder agent
|
||||
*
|
||||
* @param currentWorkflow - Optional current workflow JSON context
|
||||
* @param historyContext - Optional conversation history for multi-turn refinement
|
||||
* @param options - Optional configuration options
|
||||
*/
|
||||
export function buildCodeBuilderPrompt(
|
||||
currentWorkflow?: WorkflowJSON,
|
||||
historyContext?: HistoryContext,
|
||||
options?: BuildCodeBuilderPromptOptions,
|
||||
): ChatPromptTemplate {
|
||||
const promptSections = [
|
||||
`<role>\n${ROLE}\n</role>`,
|
||||
`<response_style>\n${RESPONSE_STYLE}\n</response_style>`,
|
||||
`<workflow_rules>\n${WORKFLOW_RULES}\n</workflow_rules>`,
|
||||
`<workflow_patterns>\n${WORKFLOW_PATTERNS}\n</workflow_patterns>`,
|
||||
`<expression_reference>\n${EXPRESSION_REFERENCE}\n</expression_reference>`,
|
||||
`<additional_functions>\n${ADDITIONAL_FUNCTIONS}\n</additional_functions>`,
|
||||
`<mandatory_workflow_process>\n${MANDATORY_WORKFLOW}\n</mandatory_workflow_process>`,
|
||||
];
|
||||
|
||||
if (options?.planOutput) {
|
||||
promptSections.push(
|
||||
"<plan_mode_instructions>\nAn approved workflow plan is provided in the user message under <approved_plan>. Use this plan as the authoritative specification for what to build. Your Step 1 analysis should follow this plan. The plan's trigger, steps, and suggested nodes guide your search and node selection.\n</plan_mode_instructions>",
|
||||
);
|
||||
}
|
||||
|
||||
const systemMessage = promptSections.join('\n\n');
|
||||
|
||||
// User message template
|
||||
const userMessageParts: string[] = [];
|
||||
|
||||
// 1. Compacted summary (if exists from previous compactions)
|
||||
if (historyContext?.previousSummary) {
|
||||
userMessageParts.push(
|
||||
`<conversation_summary>\n${escapeCurlyBrackets(historyContext.previousSummary)}\n</conversation_summary>`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Previous user requests (raw messages for recent context)
|
||||
if (historyContext?.userMessages && historyContext.userMessages.length > 0) {
|
||||
userMessageParts.push('<previous_requests>');
|
||||
historyContext.userMessages.forEach((msg, i) => {
|
||||
userMessageParts.push(`${i + 1}. ${escapeCurlyBrackets(msg)}`);
|
||||
});
|
||||
userMessageParts.push('</previous_requests>');
|
||||
}
|
||||
|
||||
// 3. Current workflow context (with line numbers for text editor)
|
||||
if (currentWorkflow) {
|
||||
// Use pre-generated code if provided (ensures text editor and prompt match),
|
||||
// otherwise generate with execution context
|
||||
const workflowCode =
|
||||
options?.preGeneratedCode ??
|
||||
generateWorkflowCode({
|
||||
workflow: currentWorkflow,
|
||||
executionSchema: options?.executionSchema,
|
||||
executionData: options?.executionData,
|
||||
expressionValues: options?.expressionValues,
|
||||
valuesExcluded: options?.valuesExcluded,
|
||||
pinnedNodes: options?.pinnedNodes,
|
||||
});
|
||||
|
||||
// Format as file with line numbers (matches view command output)
|
||||
// Include SDK import so LLM sees the same code that's in the text editor
|
||||
const codeWithImport = `${SDK_IMPORT_STATEMENT}\n\n${workflowCode}`;
|
||||
const formattedCode = formatCodeWithLineNumbers(codeWithImport);
|
||||
const escapedCode = escapeCurlyBrackets(formattedCode);
|
||||
userMessageParts.push(`<workflow_file path="/workflow.js">\n${escapedCode}\n</workflow_file>`);
|
||||
} else {
|
||||
userMessageParts.push(
|
||||
'<workflow_file path="/workflow.js">\nNo file exists yet. Use the `create` command to write the initial workflow code.\n</workflow_file>',
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Approved plan (when plan mode feeds into code builder)
|
||||
if (options?.planOutput) {
|
||||
userMessageParts.push(
|
||||
`<approved_plan>\n${escapeCurlyBrackets(formatPlanAsText(options.planOutput))}\n</approved_plan>`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Wrap user message in XML tag for easy extraction when loading sessions
|
||||
if (userMessageParts.length > 0) {
|
||||
userMessageParts.push('<user_request>');
|
||||
userMessageParts.push('{userMessage}');
|
||||
userMessageParts.push('</user_request>');
|
||||
} else {
|
||||
userMessageParts.push('{userMessage}');
|
||||
}
|
||||
|
||||
const userMessageTemplate = userMessageParts.join('\n');
|
||||
|
||||
return ChatPromptTemplate.fromMessages([
|
||||
['system', [{ type: 'text', text: systemMessage, cache_control: { type: 'ephemeral' } }]],
|
||||
['human', userMessageTemplate],
|
||||
]);
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Tests for WarningTracker class
|
||||
*/
|
||||
|
||||
import type { ValidationWarning } from '../../types';
|
||||
import { WarningTracker } from '../warning-tracker';
|
||||
|
||||
describe('WarningTracker', () => {
|
||||
let tracker: WarningTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new WarningTracker();
|
||||
});
|
||||
|
||||
describe('filterNewWarnings', () => {
|
||||
it('should return all warnings when none have been seen', () => {
|
||||
const warnings: ValidationWarning[] = [
|
||||
{ code: 'WARN001', message: 'Warning 1' },
|
||||
{ code: 'WARN002', message: 'Warning 2' },
|
||||
];
|
||||
|
||||
const result = tracker.filterNewWarnings(warnings);
|
||||
expect(result).toEqual(warnings);
|
||||
});
|
||||
|
||||
it('should filter out previously seen warnings', () => {
|
||||
const warning1: ValidationWarning = { code: 'WARN001', message: 'Warning 1' };
|
||||
const warning2: ValidationWarning = { code: 'WARN002', message: 'Warning 2' };
|
||||
|
||||
tracker.markAsSeen([warning1]);
|
||||
|
||||
const result = tracker.filterNewWarnings([warning1, warning2]);
|
||||
expect(result).toEqual([warning2]);
|
||||
});
|
||||
|
||||
it('should use code|nodeName|parameterPath as deduplication key', () => {
|
||||
const warning1: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Old message',
|
||||
nodeName: 'Node1',
|
||||
parameterPath: 'path.to.param',
|
||||
};
|
||||
|
||||
const warning2: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'New message', // Different message but same key
|
||||
nodeName: 'Node1',
|
||||
parameterPath: 'path.to.param',
|
||||
};
|
||||
|
||||
tracker.markAsSeen([warning1]);
|
||||
|
||||
const result = tracker.filterNewWarnings([warning2]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should treat warnings with same code but different node as different', () => {
|
||||
const warning1: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Warning',
|
||||
nodeName: 'Node1',
|
||||
};
|
||||
|
||||
const warning2: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Warning',
|
||||
nodeName: 'Node2',
|
||||
};
|
||||
|
||||
tracker.markAsSeen([warning1]);
|
||||
|
||||
const result = tracker.filterNewWarnings([warning2]);
|
||||
expect(result).toEqual([warning2]);
|
||||
});
|
||||
|
||||
it('should handle warnings without optional fields', () => {
|
||||
const warning1: ValidationWarning = { code: 'WARN001', message: 'Warning 1' };
|
||||
const warning2: ValidationWarning = { code: 'WARN001', message: 'Warning 2' };
|
||||
|
||||
// Same code, no nodeName or parameterPath - should be same key
|
||||
tracker.markAsSeen([warning1]);
|
||||
|
||||
const result = tracker.filterNewWarnings([warning2]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when all warnings are seen', () => {
|
||||
const warnings: ValidationWarning[] = [
|
||||
{ code: 'WARN001', message: 'Warning 1' },
|
||||
{ code: 'WARN002', message: 'Warning 2' },
|
||||
];
|
||||
|
||||
tracker.markAsSeen(warnings);
|
||||
|
||||
const result = tracker.filterNewWarnings(warnings);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsSeen', () => {
|
||||
it('should add warnings to seen set', () => {
|
||||
const warning: ValidationWarning = { code: 'WARN001', message: 'Warning' };
|
||||
|
||||
tracker.markAsSeen([warning]);
|
||||
|
||||
const result = tracker.filterNewWarnings([warning]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
tracker.markAsSeen([]);
|
||||
|
||||
const warning: ValidationWarning = { code: 'WARN001', message: 'Warning' };
|
||||
const result = tracker.filterNewWarnings([warning]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPreExisting', () => {
|
||||
it('should mark warnings as pre-existing', () => {
|
||||
const warning: ValidationWarning = { code: 'WARN001', message: 'Warning', nodeName: 'Node1' };
|
||||
|
||||
tracker.markAsPreExisting([warning]);
|
||||
|
||||
expect(tracker.isPreExisting(warning)).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark multiple warnings as pre-existing', () => {
|
||||
const warnings: ValidationWarning[] = [
|
||||
{ code: 'WARN001', message: 'Warning 1' },
|
||||
{ code: 'WARN002', message: 'Warning 2' },
|
||||
];
|
||||
|
||||
tracker.markAsPreExisting(warnings);
|
||||
|
||||
expect(tracker.isPreExisting(warnings[0])).toBe(true);
|
||||
expect(tracker.isPreExisting(warnings[1])).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
tracker.markAsPreExisting([]);
|
||||
|
||||
const warning: ValidationWarning = { code: 'WARN001', message: 'Warning' };
|
||||
expect(tracker.isPreExisting(warning)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreExisting', () => {
|
||||
it('should return false for warnings not marked as pre-existing', () => {
|
||||
const warning: ValidationWarning = { code: 'WARN001', message: 'Warning' };
|
||||
|
||||
expect(tracker.isPreExisting(warning)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match by key (code|nodeName|parameterPath), not message', () => {
|
||||
const original: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Old message',
|
||||
nodeName: 'Node1',
|
||||
parameterPath: 'path.to.param',
|
||||
};
|
||||
|
||||
tracker.markAsPreExisting([original]);
|
||||
|
||||
const sameKey: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Different message',
|
||||
nodeName: 'Node1',
|
||||
parameterPath: 'path.to.param',
|
||||
};
|
||||
|
||||
expect(tracker.isPreExisting(sameKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match warnings with different nodeName', () => {
|
||||
const warning1: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Warning',
|
||||
nodeName: 'Node1',
|
||||
};
|
||||
|
||||
tracker.markAsPreExisting([warning1]);
|
||||
|
||||
const warning2: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Warning',
|
||||
nodeName: 'Node2',
|
||||
};
|
||||
|
||||
expect(tracker.isPreExisting(warning2)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Warning Tracker
|
||||
*
|
||||
* Tracks validation warnings that have been shown to the agent to avoid
|
||||
* repeating the same warnings. Uses code|nodeName|parameterPath as the
|
||||
* deduplication key, allowing message content to change while the location
|
||||
* stays the same.
|
||||
*/
|
||||
|
||||
import type { ValidationWarning } from '../types';
|
||||
|
||||
/**
|
||||
* Generates a unique key for a warning based on its location.
|
||||
*
|
||||
* The key format is: code|nodeName|parameterPath
|
||||
* This allows deduplication by location rather than message content.
|
||||
*
|
||||
* @param warning - The warning to generate a key for
|
||||
* @returns A unique string key
|
||||
*/
|
||||
function getWarningKey(warning: ValidationWarning): string {
|
||||
return `${warning.code}|${warning.nodeName ?? ''}|${warning.parameterPath ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which validation warnings have been shown to the agent.
|
||||
*
|
||||
* Consolidates warning deduplication logic that was previously duplicated
|
||||
* in multiple places in the code builder agent.
|
||||
*/
|
||||
export class WarningTracker {
|
||||
private seenWarnings = new Set<string>();
|
||||
private preExistingKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Mark warnings as pre-existing (discovered before the agent starts editing).
|
||||
* Pre-existing warnings are annotated when shown to the agent so it can
|
||||
* distinguish them from warnings caused by its own changes.
|
||||
*
|
||||
* @param warnings - Array of warnings found in the existing workflow
|
||||
*/
|
||||
markAsPreExisting(warnings: ValidationWarning[]): void {
|
||||
for (const w of warnings) {
|
||||
this.preExistingKeys.add(getWarningKey(w));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a warning was pre-existing in the workflow before the agent edited it.
|
||||
*
|
||||
* @param warning - The warning to check
|
||||
* @returns true if the warning was marked as pre-existing
|
||||
*/
|
||||
isPreExisting(warning: ValidationWarning): boolean {
|
||||
return this.preExistingKeys.has(getWarningKey(warning));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter warnings to only include those that haven't been seen before.
|
||||
*
|
||||
* @param warnings - Array of warnings to filter
|
||||
* @returns Array of warnings that are new (not previously seen)
|
||||
*/
|
||||
filterNewWarnings(warnings: ValidationWarning[]): ValidationWarning[] {
|
||||
return warnings.filter((warning) => !this.seenWarnings.has(getWarningKey(warning)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark warnings as seen so they won't be returned by filterNewWarnings.
|
||||
*
|
||||
* @param warnings - Array of warnings to mark as seen
|
||||
*/
|
||||
markAsSeen(warnings: ValidationWarning[]): void {
|
||||
for (const warning of warnings) {
|
||||
this.seenWarnings.add(getWarningKey(warning));
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tests for CodeBuilderAgent pre-validation of existing workflows.
|
||||
*
|
||||
* Verifies that when a currentWorkflow is provided, the agent pre-validates
|
||||
* it and marks discovered warnings as pre-existing before the agentic loop.
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { CodeBuilderAgent } from '../code-builder-agent';
|
||||
|
||||
const mockFromJSON = jest.fn();
|
||||
|
||||
// Mock workflow-sdk to control parse/validate behavior
|
||||
jest.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: jest.fn(),
|
||||
validateWorkflow: jest.fn(),
|
||||
generateWorkflowCode: jest.fn().mockReturnValue('// generated code'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
workflow: { fromJSON: (...args: unknown[]) => mockFromJSON(...args) },
|
||||
}));
|
||||
|
||||
// Mock the prompts module to avoid complex prompt building
|
||||
jest.mock('../prompts', () => ({
|
||||
buildCodeBuilderPrompt: jest.fn().mockReturnValue({
|
||||
formatMessages: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { parseWorkflowCodeToBuilder, validateWorkflow } = require('@n8n/workflow-sdk') as {
|
||||
parseWorkflowCodeToBuilder: jest.Mock;
|
||||
validateWorkflow: jest.Mock;
|
||||
};
|
||||
|
||||
const MOCK_WORKFLOW: Partial<IWorkflowBase> = {
|
||||
id: 'test-wf-1',
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1.1,
|
||||
position: [240, 300],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
function createMockBuilder(warnings: Array<{ code: string; message: string }> = []) {
|
||||
return {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(MOCK_WORKFLOW),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockLlm(): BaseChatModel {
|
||||
const response = new AIMessage({
|
||||
content: '```typescript\nconst workflow = builder.addNode(...);\n```',
|
||||
tool_calls: [],
|
||||
response_metadata: { usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
});
|
||||
|
||||
return {
|
||||
bindTools: jest.fn().mockReturnValue({
|
||||
invoke: jest.fn().mockResolvedValue(response),
|
||||
}),
|
||||
} as unknown as BaseChatModel;
|
||||
}
|
||||
|
||||
describe('CodeBuilderAgent pre-validation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
parseWorkflowCodeToBuilder.mockReturnValue(createMockBuilder());
|
||||
validateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
mockFromJSON.mockReturnValue(createMockBuilder());
|
||||
});
|
||||
|
||||
it('should call validateExistingWorkflow when currentWorkflow is provided', async () => {
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat(
|
||||
{
|
||||
id: 'msg-1',
|
||||
message: 'Add a Set node',
|
||||
workflowContext: { currentWorkflow: MOCK_WORKFLOW },
|
||||
},
|
||||
'user-1',
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(MOCK_WORKFLOW);
|
||||
});
|
||||
|
||||
it('should not call validateExistingWorkflow when no currentWorkflow', async () => {
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat({ id: 'msg-2', message: 'Create a workflow' }, 'user-1')) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(mockFromJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should continue normally when pre-validation throws', async () => {
|
||||
mockFromJSON.mockImplementation(() => {
|
||||
throw new Error('fromJSON failed');
|
||||
});
|
||||
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat(
|
||||
{
|
||||
id: 'msg-3',
|
||||
message: 'Add a Set node',
|
||||
workflowContext: { currentWorkflow: MOCK_WORKFLOW },
|
||||
},
|
||||
'user-1',
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should still produce a workflow despite pre-validation failure
|
||||
const workflowUpdate = chunks.find((c) =>
|
||||
c.messages?.some((m: { type: string }) => m.type === 'workflow-updated'),
|
||||
);
|
||||
expect(workflowUpdate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should tag pre-existing warnings in validation feedback', async () => {
|
||||
const preExistingWarning = {
|
||||
code: 'WARN_EXISTING',
|
||||
message: 'Already broken',
|
||||
nodeName: 'Node1',
|
||||
};
|
||||
|
||||
// Pre-validate returns warnings from the existing workflow
|
||||
mockFromJSON.mockReturnValue(createMockBuilder([preExistingWarning]));
|
||||
|
||||
// The agent's own parse also returns the same warning (still present)
|
||||
// plus a new one
|
||||
const newWarning = { code: 'WARN_NEW', message: 'Newly introduced' };
|
||||
const agentBuilder = createMockBuilder();
|
||||
agentBuilder.validate.mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [preExistingWarning, newWarning],
|
||||
});
|
||||
parseWorkflowCodeToBuilder.mockReturnValue(agentBuilder);
|
||||
|
||||
// First call returns warnings, second (after "fix") returns clean
|
||||
let parseCallCount = 0;
|
||||
const originalToJSON = agentBuilder.toJSON;
|
||||
agentBuilder.toJSON.mockImplementation(() => {
|
||||
parseCallCount++;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return originalToJSON();
|
||||
});
|
||||
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat(
|
||||
{
|
||||
id: 'msg-4',
|
||||
message: 'Modify the workflow',
|
||||
workflowContext: { currentWorkflow: MOCK_WORKFLOW },
|
||||
},
|
||||
'user-1',
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Pre-validation should have been called
|
||||
expect(mockFromJSON).toHaveBeenCalledWith(MOCK_WORKFLOW);
|
||||
});
|
||||
});
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Tests for CodeBuilderAgent LangSmith tracing output.
|
||||
*
|
||||
* Verifies that the parent LangSmith trace run includes the generated
|
||||
* workflow JSON as output when a workflow is successfully produced.
|
||||
*/
|
||||
|
||||
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
|
||||
import { CodeBuilderAgent } from '../code-builder-agent';
|
||||
|
||||
// Mock workflow-sdk to control parse/validate behavior
|
||||
jest.mock('@n8n/workflow-sdk', () => ({
|
||||
parseWorkflowCodeToBuilder: jest.fn(),
|
||||
validateWorkflow: jest.fn(),
|
||||
generateWorkflowCode: jest.fn().mockReturnValue('// generated code'),
|
||||
}));
|
||||
|
||||
// Mock the prompts module to avoid complex prompt building
|
||||
jest.mock('../prompts', () => ({
|
||||
buildCodeBuilderPrompt: jest.fn().mockReturnValue({
|
||||
formatMessages: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { parseWorkflowCodeToBuilder, validateWorkflow } = require('@n8n/workflow-sdk') as {
|
||||
parseWorkflowCodeToBuilder: jest.Mock;
|
||||
validateWorkflow: jest.Mock;
|
||||
};
|
||||
|
||||
const MOCK_WORKFLOW: WorkflowJSON = {
|
||||
id: 'test-wf-1',
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1.1,
|
||||
position: [240, 300],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
} as unknown as WorkflowJSON;
|
||||
|
||||
/**
|
||||
* Create a mock LLM that returns a TypeScript code block on invoke.
|
||||
* The code block triggers final response handling (no tool calls).
|
||||
*/
|
||||
function createMockLlm(): BaseChatModel {
|
||||
const response = new AIMessage({
|
||||
content: '```typescript\nconst workflow = builder.addNode(...);\n```',
|
||||
tool_calls: [],
|
||||
response_metadata: { usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
});
|
||||
|
||||
return {
|
||||
bindTools: jest.fn().mockReturnValue({
|
||||
invoke: jest.fn().mockResolvedValue(response),
|
||||
}),
|
||||
} as unknown as BaseChatModel;
|
||||
}
|
||||
|
||||
function createMockBuilder() {
|
||||
return {
|
||||
regenerateNodeIds: jest.fn(),
|
||||
validate: jest.fn().mockReturnValue({ valid: true, errors: [], warnings: [] }),
|
||||
generatePinData: jest.fn(),
|
||||
toJSON: jest.fn().mockReturnValue(MOCK_WORKFLOW),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom callback handler that captures chain end outputs.
|
||||
* We use a class-based handler to ensure proper integration with
|
||||
* LangChain's CallbackManager.
|
||||
*/
|
||||
class ChainEndTracker extends BaseCallbackHandler {
|
||||
name = 'chain-end-tracker';
|
||||
|
||||
chainEndOutputs: Array<Record<string, unknown>> = [];
|
||||
|
||||
async handleChainEnd(outputs: Record<string, unknown>): Promise<void> {
|
||||
this.chainEndOutputs.push(outputs);
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeBuilderAgent tracing', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
parseWorkflowCodeToBuilder.mockReturnValue(createMockBuilder());
|
||||
validateWorkflow.mockReturnValue({ valid: true, errors: [], warnings: [] });
|
||||
});
|
||||
|
||||
it('should include workflow JSON in handleChainEnd output', async () => {
|
||||
const tracker = new ChainEndTracker();
|
||||
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
callbacks: [tracker],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat(
|
||||
{ id: 'msg-1', message: 'Create a simple workflow' },
|
||||
'user-1',
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Verify the parent run's handleChainEnd received the workflow JSON
|
||||
expect(tracker.chainEndOutputs.length).toBeGreaterThan(0);
|
||||
// Find the parent chain end (the one with iterations field)
|
||||
const parentOutput = tracker.chainEndOutputs.find((o) => 'iterations' in o);
|
||||
expect(parentOutput).toBeDefined();
|
||||
expect(parentOutput).toMatchObject({
|
||||
iterations: expect.any(Number),
|
||||
hasWorkflow: true,
|
||||
outputTrace: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'text', text: expect.any(String) }),
|
||||
]),
|
||||
output: {
|
||||
code: expect.any(String),
|
||||
workflow: JSON.stringify(MOCK_WORKFLOW),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should capture interleaved text and tool-call entries in outputTrace', async () => {
|
||||
// Iteration 1: LLM returns text + two tool calls (search_nodes, get_node_types)
|
||||
const toolCallResponse = new AIMessage({
|
||||
content: 'Let me search for the right nodes...',
|
||||
tool_calls: [
|
||||
{ name: 'search_nodes', args: { query: 'http' }, id: 'tc-1', type: 'tool_call' as const },
|
||||
{
|
||||
name: 'get_node_types',
|
||||
args: { nodeTypes: ['n8n-nodes-base.httpRequest'] },
|
||||
id: 'tc-2',
|
||||
type: 'tool_call' as const,
|
||||
},
|
||||
],
|
||||
response_metadata: { usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
});
|
||||
|
||||
// Iteration 2: LLM returns text with code block (final response)
|
||||
const codeResponse = new AIMessage({
|
||||
content: 'Here is your workflow:\n```typescript\nconst workflow = builder.addNode(...);\n```',
|
||||
tool_calls: [],
|
||||
response_metadata: { usage: { input_tokens: 200, output_tokens: 100 } },
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
const mockLlm = {
|
||||
bindTools: jest.fn().mockReturnValue({
|
||||
invoke: jest.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return callCount === 1 ? toolCallResponse : codeResponse;
|
||||
}),
|
||||
}),
|
||||
} as unknown as BaseChatModel;
|
||||
|
||||
const tracker = new ChainEndTracker();
|
||||
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: mockLlm,
|
||||
nodeTypes: [],
|
||||
callbacks: [tracker],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat(
|
||||
{ id: 'msg-3', message: 'Create an HTTP request workflow' },
|
||||
'user-1',
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const parentOutput = tracker.chainEndOutputs.find((o) => 'iterations' in o);
|
||||
expect(parentOutput).toBeDefined();
|
||||
expect(parentOutput!.hasWorkflow).toBe(true);
|
||||
|
||||
// Verify the outputTrace has the correct interleaved order
|
||||
const trace = parentOutput!.outputTrace as Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
}>;
|
||||
expect(trace).toEqual([
|
||||
{ type: 'text', text: 'Let me search for the right nodes...' },
|
||||
{ type: 'tool-call', toolName: 'search_nodes' },
|
||||
{ type: 'tool-call', toolName: 'get_node_types' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here is your workflow:\n```typescript\nconst workflow = builder.addNode(...);\n```',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set output to null when no workflow is produced', async () => {
|
||||
// Make parse fail on all attempts so no workflow is generated
|
||||
parseWorkflowCodeToBuilder.mockImplementation(() => {
|
||||
throw new Error('Parse error');
|
||||
});
|
||||
|
||||
const tracker = new ChainEndTracker();
|
||||
|
||||
const agent = new CodeBuilderAgent({
|
||||
llm: createMockLlm(),
|
||||
nodeTypes: [],
|
||||
callbacks: [tracker],
|
||||
enableTextEditor: false,
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of agent.chat({ id: 'msg-2', message: 'Create a workflow' }, 'user-1')) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// When parse fails and the error is caught, handleChainError is called.
|
||||
// If handleChainEnd was called instead, verify output is null
|
||||
const parentOutput = tracker.chainEndOutputs.find((o) => 'iterations' in o);
|
||||
if (parentOutput) {
|
||||
expect(parentOutput).toMatchObject({
|
||||
hasWorkflow: false,
|
||||
output: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Integration tests for CodeWorkflowBuilder.
|
||||
* Tests the integration between WorkflowBuilderAgent and CodeWorkflowBuilder.
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
// Mock the CodeWorkflowBuilder module
|
||||
const mockChat = jest.fn();
|
||||
jest.mock('@/code-builder/code-workflow-builder', () => {
|
||||
return {
|
||||
CodeWorkflowBuilder: jest.fn().mockImplementation(() => ({
|
||||
chat: mockChat,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock tools to avoid complex dependencies
|
||||
jest.mock('@/tools/add-node.tool', () => ({
|
||||
createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }),
|
||||
}));
|
||||
jest.mock('@/tools/connect-nodes.tool', () => ({
|
||||
createConnectNodesTool: jest.fn().mockReturnValue({ tool: { name: 'connect_nodes' } }),
|
||||
}));
|
||||
jest.mock('@/tools/node-details.tool', () => ({
|
||||
createNodeDetailsTool: jest.fn().mockReturnValue({ tool: { name: 'node_details' } }),
|
||||
}));
|
||||
jest.mock('@/tools/node-search.tool', () => ({
|
||||
createNodeSearchTool: jest.fn().mockReturnValue({ tool: { name: 'node_search' } }),
|
||||
}));
|
||||
jest.mock('@/tools/remove-node.tool', () => ({
|
||||
createRemoveNodeTool: jest.fn().mockReturnValue({ tool: { name: 'remove_node' } }),
|
||||
}));
|
||||
jest.mock('@/tools/update-node-parameters.tool', () => ({
|
||||
createUpdateNodeParametersTool: jest
|
||||
.fn()
|
||||
.mockReturnValue({ tool: { name: 'update_node_parameters' } }),
|
||||
}));
|
||||
jest.mock('@/tools/get-node-parameter.tool', () => ({
|
||||
createGetNodeParameterTool: jest.fn().mockReturnValue({ tool: { name: 'get_node_parameter' } }),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils/stream-processor', () => ({
|
||||
createStreamProcessor: jest.fn(),
|
||||
formatMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the multi-agent workflow to avoid needing real LLM instances for the planner agent
|
||||
jest.mock('@/multi-agent-workflow-subgraphs', () => ({
|
||||
createMultiAgentWorkflowWithSubgraphs: jest.fn().mockReturnValue({
|
||||
stream: jest.fn(),
|
||||
getState: jest.fn(),
|
||||
updateState: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CodeWorkflowBuilder } from '@/code-builder/code-workflow-builder';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import {
|
||||
WorkflowBuilderAgent,
|
||||
type WorkflowBuilderAgentConfig,
|
||||
type ChatPayload,
|
||||
} from '@/workflow-builder-agent';
|
||||
|
||||
describe('CodeWorkflowBuilder Integration', () => {
|
||||
let agent: WorkflowBuilderAgent;
|
||||
let mockLlm: BaseChatModel;
|
||||
let mockLogger: Logger;
|
||||
let mockCheckpointer: MemorySaver;
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
let config: WorkflowBuilderAgentConfig;
|
||||
|
||||
const mockChatFn = mockChat;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockLlm = mock<BaseChatModel>({
|
||||
_llmType: jest.fn().mockReturnValue('test-llm'),
|
||||
bindTools: jest.fn().mockReturnThis(),
|
||||
invoke: jest.fn(),
|
||||
});
|
||||
|
||||
mockLogger = mock<Logger>({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
});
|
||||
|
||||
mockCheckpointer = mock<MemorySaver>();
|
||||
mockCheckpointer.getTuple = jest.fn();
|
||||
mockCheckpointer.put = jest.fn();
|
||||
mockCheckpointer.list = jest.fn();
|
||||
|
||||
parsedNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-base.manualTrigger',
|
||||
displayName: 'Manual Trigger',
|
||||
description: 'Start workflow manually',
|
||||
version: 1.1,
|
||||
defaults: { name: 'When clicking Test workflow' },
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
group: ['trigger'],
|
||||
} as INodeTypeDescription,
|
||||
];
|
||||
|
||||
config = {
|
||||
parsedNodeTypes,
|
||||
stageLLMs: {
|
||||
supervisor: mockLlm,
|
||||
responder: mockLlm,
|
||||
discovery: mockLlm,
|
||||
builder: mockLlm,
|
||||
parameterUpdater: mockLlm,
|
||||
planner: mockLlm,
|
||||
},
|
||||
logger: mockLogger,
|
||||
checkpointer: mockCheckpointer,
|
||||
};
|
||||
|
||||
agent = new WorkflowBuilderAgent(config);
|
||||
});
|
||||
|
||||
describe('routing to CodeWorkflowBuilder', () => {
|
||||
it('should route to CodeWorkflowBuilder when codeBuilder feature flag is true', async () => {
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Generated workflow',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock the chat method to return an async generator
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
yield mockStreamOutput;
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-123',
|
||||
message: 'Create a workflow that sends emails',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-123');
|
||||
const result = await generator.next();
|
||||
|
||||
// Verify CodeWorkflowBuilder was instantiated
|
||||
expect(CodeWorkflowBuilder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm: mockLlm,
|
||||
nodeTypes: parsedNodeTypes,
|
||||
logger: mockLogger,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify chat was called with correct arguments
|
||||
expect(mockChatFn).toHaveBeenCalledWith(payload, 'user-123', undefined);
|
||||
|
||||
// Verify output was yielded
|
||||
expect(result.value).toEqual(mockStreamOutput);
|
||||
});
|
||||
|
||||
it('should route to CodeWorkflowBuilder when codeBuilder is explicitly true', async () => {
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Generated workflow',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
yield mockStreamOutput;
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-456',
|
||||
message: 'Create a chatbot workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-456');
|
||||
await generator.next();
|
||||
|
||||
expect(CodeWorkflowBuilder).toHaveBeenCalled();
|
||||
expect(mockChatFn).toHaveBeenCalledWith(payload, 'user-456', undefined);
|
||||
});
|
||||
|
||||
it('should pass abort signal to CodeWorkflowBuilder', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
yield {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Processing...' }],
|
||||
};
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-789',
|
||||
message: 'Build a workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-789', controller.signal);
|
||||
await generator.next();
|
||||
|
||||
expect(mockChatFn).toHaveBeenCalledWith(payload, 'user-789', controller.signal);
|
||||
});
|
||||
|
||||
it('should pass nodeDefinitionDirs to CodeWorkflowBuilder', async () => {
|
||||
const configWithDirs: WorkflowBuilderAgentConfig = {
|
||||
...config,
|
||||
nodeDefinitionDirs: ['/path/to/builtin', '/path/to/community'],
|
||||
};
|
||||
const agentWithDirs = new WorkflowBuilderAgent(configWithDirs);
|
||||
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
yield {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Done' }],
|
||||
};
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-types',
|
||||
message: 'Create workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agentWithDirs.chat(payload, 'user-types');
|
||||
await generator.next();
|
||||
|
||||
expect(CodeWorkflowBuilder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nodeDefinitionDirs: ['/path/to/builtin', '/path/to/community'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should yield all stream chunks from CodeWorkflowBuilder', async () => {
|
||||
const chunks: StreamOutput[] = [
|
||||
{
|
||||
messages: [{ role: 'assistant', type: 'message', text: '<final_workflow_plan>\n' }],
|
||||
},
|
||||
{
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Planning...' }],
|
||||
},
|
||||
{
|
||||
messages: [{ role: 'assistant', type: 'message', text: '</final_workflow_plan>\n' }],
|
||||
},
|
||||
{
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Generating code...' }],
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify({
|
||||
id: 'workflow-1',
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-stream',
|
||||
message: 'Create a workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-stream');
|
||||
const results: StreamOutput[] = [];
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(chunks.length);
|
||||
expect(results).toEqual(chunks);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-agent system fallback', () => {
|
||||
it('should NOT route to CodeWorkflowBuilder when codeBuilder is false', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-multi-agent',
|
||||
message: 'Create a workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: false,
|
||||
},
|
||||
};
|
||||
|
||||
// For multi-agent system, we need to mock the stream processor
|
||||
// since it won't use CodeWorkflowBuilder
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const { createStreamProcessor } = jest.requireMock('@/utils/stream-processor') as {
|
||||
createStreamProcessor: jest.Mock;
|
||||
};
|
||||
createStreamProcessor.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Multi-agent response' }],
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const generator = agent.chat(payload, 'user-multi');
|
||||
await generator.next();
|
||||
|
||||
// CodeWorkflowBuilder should NOT be called
|
||||
expect(CodeWorkflowBuilder).not.toHaveBeenCalled();
|
||||
expect(mockChatFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('context handling', () => {
|
||||
it('should pass workflow context to CodeWorkflowBuilder', async () => {
|
||||
const currentWorkflow = {
|
||||
id: 'existing-workflow',
|
||||
name: 'Existing Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1.1,
|
||||
position: [240, 300] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
mockChatFn.mockImplementation(async function* () {
|
||||
yield {
|
||||
messages: [
|
||||
{ role: 'assistant', type: 'message', text: 'Processing existing workflow...' },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-context',
|
||||
message: 'Add a Slack notification node',
|
||||
workflowContext: {
|
||||
currentWorkflow,
|
||||
},
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-context');
|
||||
await generator.next();
|
||||
|
||||
// Verify the payload with workflow context was passed
|
||||
expect(mockChatFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowContext: expect.objectContaining({
|
||||
currentWorkflow: expect.objectContaining({
|
||||
id: 'existing-workflow',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
'user-context',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate errors from CodeWorkflowBuilder', async () => {
|
||||
// eslint-disable-next-line require-yield
|
||||
mockChatFn.mockImplementation(async function* (): AsyncGenerator<StreamOutput> {
|
||||
throw new Error('CodeWorkflowBuilder error');
|
||||
});
|
||||
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-error',
|
||||
message: 'Create a workflow',
|
||||
featureFlags: {
|
||||
codeBuilder: true,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload, 'user-error');
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _ of generator) {
|
||||
// Consume generator
|
||||
}
|
||||
}).rejects.toThrow('CodeWorkflowBuilder error');
|
||||
});
|
||||
});
|
||||
});
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import type { ChatPayload } from '../../workflow-builder-agent';
|
||||
import { CodeWorkflowBuilder, createCodeWorkflowBuilder } from '../code-workflow-builder';
|
||||
import type { CodeWorkflowBuilderConfig } from '../code-workflow-builder';
|
||||
|
||||
// Create a mock LLM that returns valid planning response
|
||||
const createMockLLM = () => {
|
||||
const mockResponse = {
|
||||
content:
|
||||
'<final_plan>## Overview\nTest plan.\n\n## Nodes\n- Test\n\n## Flow\nA → B\n\n## Key Points\nNone</final_plan>',
|
||||
tool_calls: [],
|
||||
response_metadata: { usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
};
|
||||
|
||||
return {
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
bindTools: jest.fn().mockReturnValue({
|
||||
invoke: jest.fn().mockResolvedValue(mockResponse),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Create mock node types
|
||||
const mockNodeTypes = [
|
||||
{
|
||||
name: 'n8n-nodes-base.manualTrigger',
|
||||
displayName: 'Manual Trigger',
|
||||
description: 'Start workflow manually',
|
||||
version: 1.1,
|
||||
group: ['trigger'],
|
||||
defaults: { name: 'When clicking Test workflow' },
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CodeWorkflowBuilder', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with required config', () => {
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
|
||||
it('should accept optional logger', () => {
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
logger: mockLogger as unknown as CodeWorkflowBuilderConfig['logger'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
|
||||
it('should accept optional nodeDefinitionDirs', () => {
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
nodeDefinitionDirs: ['/path/to/generated/types'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
|
||||
it('should accept optional onGenerationSuccess callback', () => {
|
||||
const onGenerationSuccess = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
onGenerationSuccess,
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
it('should yield stream output chunks', async () => {
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-1',
|
||||
message: 'What is n8n?',
|
||||
};
|
||||
|
||||
const generator = builder.chat(payload, 'user-123');
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[0]).toHaveProperty('messages');
|
||||
});
|
||||
|
||||
it('should handle payload with workflowContext', async () => {
|
||||
const currentWorkflow = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1.1,
|
||||
position: [240, 300] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-2',
|
||||
message: 'Add a Slack node',
|
||||
workflowContext: {
|
||||
currentWorkflow,
|
||||
},
|
||||
};
|
||||
|
||||
const generator = builder.chat(payload, 'user-123');
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should respect abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-3',
|
||||
message: 'Create a workflow',
|
||||
};
|
||||
|
||||
const generator = builder.chat(payload, 'user-123', controller.signal);
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
// Should receive chunks even when aborted (including error message)
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call logger.debug when logger is provided', async () => {
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const config: CodeWorkflowBuilderConfig = {
|
||||
llm: createMockLLM() as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
nodeTypes: mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
logger: mockLogger as unknown as CodeWorkflowBuilderConfig['logger'],
|
||||
};
|
||||
|
||||
const builder = new CodeWorkflowBuilder(config);
|
||||
const payload: ChatPayload = {
|
||||
id: 'test-4',
|
||||
message: 'Hello',
|
||||
};
|
||||
|
||||
const generator = builder.chat(payload, 'user-456');
|
||||
|
||||
// Consume the generator
|
||||
for await (const _ of generator) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('CodeWorkflowBuilder starting', {
|
||||
userId: 'user-456',
|
||||
messageLength: 5,
|
||||
hasSessionHandler: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCodeWorkflowBuilder', () => {
|
||||
it('should create a CodeWorkflowBuilder with single LLM', () => {
|
||||
const mockLLM = createMockLLM();
|
||||
|
||||
const builder = createCodeWorkflowBuilder(
|
||||
mockLLM as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
);
|
||||
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
|
||||
it('should accept optional logger', () => {
|
||||
const mockLLM = createMockLLM();
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const builder = createCodeWorkflowBuilder(
|
||||
mockLLM as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
{ logger: mockLogger as unknown as CodeWorkflowBuilderConfig['logger'] },
|
||||
);
|
||||
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
|
||||
it('should accept optional nodeDefinitionDirs', () => {
|
||||
const mockLLM = createMockLLM();
|
||||
|
||||
const builder = createCodeWorkflowBuilder(
|
||||
mockLLM as unknown as CodeWorkflowBuilderConfig['llm'],
|
||||
mockNodeTypes as unknown as CodeWorkflowBuilderConfig['nodeTypes'],
|
||||
{ nodeDefinitionDirs: ['/path/to/types'] },
|
||||
);
|
||||
|
||||
expect(builder).toBeInstanceOf(CodeWorkflowBuilder);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Tests for code-builder constants
|
||||
*/
|
||||
|
||||
import {
|
||||
MAX_AGENT_ITERATIONS,
|
||||
MAX_VALIDATE_ATTEMPTS,
|
||||
FIX_VALIDATION_ERRORS_INSTRUCTION,
|
||||
TEXT_EDITOR_TOOL,
|
||||
VALIDATE_TOOL,
|
||||
} from '../constants';
|
||||
|
||||
describe('code-builder constants', () => {
|
||||
describe('iteration limits', () => {
|
||||
it('should have MAX_AGENT_ITERATIONS set to 50', () => {
|
||||
expect(MAX_AGENT_ITERATIONS).toBe(50);
|
||||
});
|
||||
|
||||
it('should have MAX_VALIDATE_ATTEMPTS set to 10', () => {
|
||||
expect(MAX_VALIDATE_ATTEMPTS).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIX_VALIDATION_ERRORS_INSTRUCTION', () => {
|
||||
it('should include numbered steps for fixing errors', () => {
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('1.');
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('2.');
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('3.');
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('4.');
|
||||
});
|
||||
|
||||
it('should remind agent to use get_node_types first', () => {
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('get_node_types');
|
||||
});
|
||||
|
||||
it('should mention str_replace and insert as fix options', () => {
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('str_replace');
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('insert');
|
||||
});
|
||||
|
||||
it('should instruct to validate after fixing', () => {
|
||||
expect(FIX_VALIDATION_ERRORS_INSTRUCTION).toContain('validate_workflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEXT_EDITOR_TOOL', () => {
|
||||
it('should have correct type and name', () => {
|
||||
expect(TEXT_EDITOR_TOOL).toEqual({
|
||||
type: 'text_editor_20250728',
|
||||
name: 'str_replace_based_edit_tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALIDATE_TOOL', () => {
|
||||
it('should have correct function configuration', () => {
|
||||
expect(VALIDATE_TOOL.type).toBe('function');
|
||||
expect(VALIDATE_TOOL.function.name).toBe('validate_workflow');
|
||||
expect(VALIDATE_TOOL.function.description).toContain('Validate');
|
||||
expect(VALIDATE_TOOL.function.parameters.required).toContain('path');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,670 @@
|
||||
/**
|
||||
* Simplified Node Get Tool for CodeWorkflowBuilder
|
||||
*
|
||||
* Returns the full TypeScript type definition for a specific node
|
||||
* from the generated workflow-sdk types.
|
||||
*/
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Validate a path component to prevent path traversal attacks.
|
||||
* Rejects empty values, path separators, traversal sequences, and null bytes.
|
||||
*/
|
||||
export function isValidPathComponent(component: string): boolean {
|
||||
if (!component || component.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject null bytes
|
||||
if (component.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject path separators
|
||||
if (component.includes('/') || component.includes('\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject parent directory traversal
|
||||
if (component === '..' || component.startsWith('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a resolved path is within the expected base directory.
|
||||
* Prevents path traversal even if components pass basic validation.
|
||||
*/
|
||||
export function validatePathWithinBase(filePath: string, baseDir: string): boolean {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const resolvedBase = resolve(baseDir);
|
||||
|
||||
// Path must start with base directory (with trailing separator to prevent prefix attacks)
|
||||
return resolvedPath.startsWith(resolvedBase + '/') || resolvedPath === resolvedBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the paths to the generated nodes directories.
|
||||
* Searches multiple directories in order (built-in first, then community).
|
||||
* Falls back to ~/.n8n/node-definitions if no dirs are provided.
|
||||
*/
|
||||
function getGeneratedNodesPaths(nodeDefinitionDirs?: string[]): string[] {
|
||||
if (nodeDefinitionDirs && nodeDefinitionDirs.length > 0) {
|
||||
return nodeDefinitionDirs.map((dir) => join(dir, 'nodes'));
|
||||
}
|
||||
|
||||
// Default to ~/.n8n/node-definitions (same location as runtime and CLI)
|
||||
const defaultTypesDir = join(homedir(), '.n8n', 'node-definitions');
|
||||
return [join(defaultTypesDir, 'nodes')];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first nodes path that contains the given node directory.
|
||||
* Returns { nodesPath, nodeDir } or null if not found in any dir.
|
||||
*/
|
||||
function findNodeDir(
|
||||
parsed: { packageName: string; nodeName: string },
|
||||
nodesPaths: string[],
|
||||
): { nodesPath: string; nodeDir: string } | null {
|
||||
for (const nodesPath of nodesPaths) {
|
||||
const nodeDir = join(nodesPath, parsed.packageName, parsed.nodeName);
|
||||
if (existsSync(nodeDir)) {
|
||||
return { nodesPath, nodeDir };
|
||||
}
|
||||
}
|
||||
|
||||
// Tool variant fallback: e.g. "httpRequestTool" -> "httpRequest"
|
||||
// Tool variants share type definitions with their base node
|
||||
if (parsed.nodeName.endsWith('Tool')) {
|
||||
const baseName = parsed.nodeName.slice(0, -4);
|
||||
for (const nodesPath of nodesPaths) {
|
||||
const nodeDir = join(nodesPath, parsed.packageName, baseName);
|
||||
if (existsSync(nodeDir)) {
|
||||
return { nodesPath, nodeDir };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string to snake_case for file/folder naming
|
||||
* Examples: "runOnceForAllItems" -> "run_once_for_all_items"
|
||||
*/
|
||||
function toSnakeCase(str: string): string {
|
||||
return str
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/^_/, '')
|
||||
.replace(/[-\s]+/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node uses split version structure (v{N}/ directory vs v{N}.ts file)
|
||||
* Returns true if the version directory exists, regardless of whether a flat file also exists.
|
||||
* This ensures we prefer split structure (smaller, specific files) over flat files when both exist.
|
||||
*/
|
||||
function isSplitVersionStructure(nodeDir: string, versionStr: string): boolean {
|
||||
const versionDir = join(nodeDir, versionStr);
|
||||
// Return true if directory exists (prefer split structure over flat file)
|
||||
return existsSync(versionDir) && statSync(versionDir).isDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available discriminators for a split version
|
||||
* Returns { resources, modes } based on directory structure
|
||||
*/
|
||||
function getAvailableDiscriminators(
|
||||
nodeDir: string,
|
||||
versionStr: string,
|
||||
): { resources?: string[]; modes?: string[] } {
|
||||
const versionDir = join(nodeDir, versionStr);
|
||||
if (!existsSync(versionDir)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(versionDir, { withFileTypes: true });
|
||||
const resources: string[] = [];
|
||||
const modes: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('resource_')) {
|
||||
resources.push(entry.name.replace('resource_', ''));
|
||||
} else if (entry.isFile() && entry.name.startsWith('mode_') && entry.name.endsWith('.ts')) {
|
||||
modes.push(entry.name.replace('mode_', '').replace('.ts', ''));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resources: resources.length > 0 ? resources : undefined,
|
||||
modes: modes.length > 0 ? modes : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a node ID into package and node name components
|
||||
* Examples:
|
||||
* "n8n-nodes-base.httpRequest" -> { package: "n8n-nodes-base", nodeName: "httpRequest" }
|
||||
* "@n8n/n8n-nodes-langchain.agent" -> { package: "n8n-nodes-langchain", nodeName: "agent" }
|
||||
*/
|
||||
function parseNodeId(nodeId: string): { packageName: string; nodeName: string } | null {
|
||||
// Handle @n8n/ prefixed packages (langchain)
|
||||
if (nodeId.startsWith('@n8n/')) {
|
||||
const withoutPrefix = nodeId.slice(5); // Remove "@n8n/"
|
||||
const dotIndex = withoutPrefix.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
packageName: withoutPrefix.slice(0, dotIndex),
|
||||
nodeName: withoutPrefix.slice(dotIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular packages
|
||||
const dotIndex = nodeId.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
packageName: nodeId.slice(0, dotIndex),
|
||||
nodeName: nodeId.slice(dotIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available versions for a node
|
||||
* Returns array of version strings like ['v34', 'v2'] sorted by version descending
|
||||
*/
|
||||
function getNodeVersions(nodeId: string, nodeDefinitionDirs?: string[]): string[] {
|
||||
const parsed = parseNodeId(nodeId);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodesPaths = getGeneratedNodesPaths(nodeDefinitionDirs);
|
||||
const found = findNodeDir(parsed, nodesPaths);
|
||||
|
||||
if (!found) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { nodeDir } = found;
|
||||
|
||||
try {
|
||||
const entries = readdirSync(nodeDir, { withFileTypes: true });
|
||||
const versions: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Flat file: v1.ts, v2.ts, etc. (exclude schema files and index.ts)
|
||||
if (
|
||||
entry.isFile() &&
|
||||
entry.name.startsWith('v') &&
|
||||
entry.name.endsWith('.ts') &&
|
||||
entry.name !== 'index.ts' &&
|
||||
!entry.name.endsWith('.schema.js')
|
||||
) {
|
||||
versions.push(entry.name.replace('.ts', ''));
|
||||
}
|
||||
// Split directory: v1/, v2/, etc.
|
||||
else if (entry.isDirectory() && /^v\d+$/.test(entry.name)) {
|
||||
versions.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by numeric version descending
|
||||
versions.sort((a, b) => {
|
||||
const aNum = parseInt(a.slice(1), 10);
|
||||
const bNum = parseInt(b.slice(1), 10);
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
return versions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of path resolution - either a file path or an error */
|
||||
interface PathResolutionResult {
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
requiresDiscriminators?: boolean;
|
||||
availableDiscriminators?: { resources?: string[]; modes?: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve resource/operation path for split version structure
|
||||
*/
|
||||
function resolveResourceOperationPath(
|
||||
nodeDir: string,
|
||||
targetVersion: string,
|
||||
nodeId: string,
|
||||
available: { resources?: string[]; modes?: string[] },
|
||||
discriminators?: { resource?: string; operation?: string },
|
||||
): PathResolutionResult {
|
||||
if (!discriminators?.resource || !discriminators?.operation) {
|
||||
return {
|
||||
error: `Error: Node '${nodeId}' requires resource and operation discriminators. Available resources: ${available.resources?.join(', ')}. Use search_nodes to see all options.`,
|
||||
requiresDiscriminators: true,
|
||||
availableDiscriminators: available,
|
||||
};
|
||||
}
|
||||
|
||||
// Security: Validate discriminator values to prevent path traversal
|
||||
if (!isValidPathComponent(discriminators.resource)) {
|
||||
return {
|
||||
error: `Error: Invalid resource value '${discriminators.resource}' - contains invalid characters`,
|
||||
};
|
||||
}
|
||||
if (!isValidPathComponent(discriminators.operation)) {
|
||||
return {
|
||||
error: `Error: Invalid operation value '${discriminators.operation}' - contains invalid characters`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate resource
|
||||
const resourcePath = `resource_${toSnakeCase(discriminators.resource)}`;
|
||||
const resourceDir = join(nodeDir, targetVersion, resourcePath);
|
||||
if (!existsSync(resourceDir)) {
|
||||
return {
|
||||
error: `Error: Invalid resource '${discriminators.resource}' for node '${nodeId}'. Available: ${available.resources?.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate operation
|
||||
const operationFile = `operation_${toSnakeCase(discriminators.operation)}.ts`;
|
||||
const filePath = join(resourceDir, operationFile);
|
||||
|
||||
// Security: Final path validation - ensure we're still within nodeDir
|
||||
if (!validatePathWithinBase(filePath, nodeDir)) {
|
||||
return {
|
||||
error: 'Error: Invalid path - path traversal detected',
|
||||
};
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
try {
|
||||
const ops = readdirSync(resourceDir)
|
||||
.filter((f) => f.startsWith('operation_') && f.endsWith('.ts'))
|
||||
.map((f) => f.replace('operation_', '').replace('.ts', ''));
|
||||
return {
|
||||
error: `Error: Invalid operation '${discriminators.operation}' for resource '${discriminators.resource}'. Available: ${ops.join(', ')}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
error: `Error: Could not read operations for resource '${discriminators.resource}'`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve mode path for split version structure
|
||||
*/
|
||||
function resolveModePath(
|
||||
nodeDir: string,
|
||||
targetVersion: string,
|
||||
nodeId: string,
|
||||
available: { resources?: string[]; modes?: string[] },
|
||||
mode?: string,
|
||||
): PathResolutionResult {
|
||||
if (!mode) {
|
||||
return {
|
||||
error: `Error: Node '${nodeId}' requires mode discriminator. Available modes: ${available.modes?.join(', ')}. Use search_nodes to see all options.`,
|
||||
requiresDiscriminators: true,
|
||||
availableDiscriminators: available,
|
||||
};
|
||||
}
|
||||
|
||||
// Security: Validate mode value to prevent path traversal
|
||||
if (!isValidPathComponent(mode)) {
|
||||
return {
|
||||
error: `Error: Invalid mode value '${mode}' - contains invalid characters`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate mode
|
||||
const modeFile = `mode_${toSnakeCase(mode)}.ts`;
|
||||
const filePath = join(nodeDir, targetVersion, modeFile);
|
||||
|
||||
// Security: Final path validation - ensure we're still within nodeDir
|
||||
if (!validatePathWithinBase(filePath, nodeDir)) {
|
||||
return {
|
||||
error: 'Error: Invalid path - path traversal detected',
|
||||
};
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return {
|
||||
error: `Error: Invalid mode '${mode}' for node '${nodeId}'. Available: ${available.modes?.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve file path for a specific node ID
|
||||
* This is the core resolution logic used by getNodeFilePath
|
||||
*/
|
||||
function tryGetNodeFilePath(
|
||||
nodeId: string,
|
||||
version: string | undefined,
|
||||
nodeDefinitionDirs: string[] | undefined,
|
||||
discriminators: { resource?: string; operation?: string; mode?: string } | undefined,
|
||||
): PathResolutionResult {
|
||||
const parsed = parseNodeId(nodeId);
|
||||
if (!parsed) {
|
||||
return { error: `Invalid node ID format: '${nodeId}'` };
|
||||
}
|
||||
|
||||
// Security: Validate parsed components to prevent path traversal
|
||||
if (!isValidPathComponent(parsed.packageName)) {
|
||||
return {
|
||||
error: `Error: Invalid package name in node ID '${nodeId}' - contains invalid characters`,
|
||||
};
|
||||
}
|
||||
if (!isValidPathComponent(parsed.nodeName)) {
|
||||
return {
|
||||
error: `Error: Invalid node name in node ID '${nodeId}' - contains invalid characters`,
|
||||
};
|
||||
}
|
||||
|
||||
const nodesPaths = getGeneratedNodesPaths(nodeDefinitionDirs);
|
||||
const found = findNodeDir(parsed, nodesPaths);
|
||||
|
||||
if (!found) {
|
||||
return {
|
||||
error: `Node type '${nodeId}' not found. Use search_node to find the correct node ID.`,
|
||||
};
|
||||
}
|
||||
|
||||
const { nodesPath, nodeDir } = found;
|
||||
|
||||
// Security: Final path validation - ensure we're still within nodesPath
|
||||
if (!validatePathWithinBase(nodeDir, nodesPath)) {
|
||||
return { error: 'Error: Invalid path - path traversal detected' };
|
||||
}
|
||||
|
||||
let targetVersion = version;
|
||||
|
||||
// If no version specified, find the latest version
|
||||
if (!targetVersion) {
|
||||
const versions = getNodeVersions(nodeId, nodeDefinitionDirs);
|
||||
if (versions.length === 0) {
|
||||
return { error: `No versions found for node '${nodeId}'` };
|
||||
}
|
||||
targetVersion = versions[0]; // Latest version (sorted descending)
|
||||
}
|
||||
|
||||
// Convert version to file format:
|
||||
// - "3.1" -> "v31"
|
||||
// - "31" -> "v31"
|
||||
// - "v31" -> "v31"
|
||||
// - "v3.1" -> "v31"
|
||||
if (!targetVersion.startsWith('v')) {
|
||||
targetVersion = `v${targetVersion.replace('.', '')}`;
|
||||
} else {
|
||||
targetVersion = `v${targetVersion.slice(1).replace('.', '')}`;
|
||||
}
|
||||
|
||||
// Check if this is a split version structure
|
||||
if (isSplitVersionStructure(nodeDir, targetVersion)) {
|
||||
const available = getAvailableDiscriminators(nodeDir, targetVersion);
|
||||
|
||||
// Handle resource/operation pattern
|
||||
if (available.resources && available.resources.length > 0) {
|
||||
return resolveResourceOperationPath(
|
||||
nodeDir,
|
||||
targetVersion,
|
||||
nodeId,
|
||||
available,
|
||||
discriminators,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle mode pattern
|
||||
if (available.modes && available.modes.length > 0) {
|
||||
return resolveModePath(nodeDir, targetVersion, nodeId, available, discriminators?.mode);
|
||||
}
|
||||
|
||||
// Split structure exists but no recognized discriminator directories
|
||||
return {
|
||||
error: `Error: Node '${nodeId}' has split structure but no recognized discriminators found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Flat file structure
|
||||
const filePath = join(nodeDir, `${targetVersion}.ts`);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return { error: `Version '${version}' not found for node '${nodeId}'` };
|
||||
}
|
||||
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for a node ID, optionally for a specific version and discriminators
|
||||
* If no version specified, returns the latest version
|
||||
* If node uses split structure, discriminators are required
|
||||
*
|
||||
* For tool variants (e.g., "googleCalendarTool"), falls back to the base node
|
||||
* (e.g., "googleCalendar") since tool variants don't have separate type files.
|
||||
*/
|
||||
function getNodeFilePath(
|
||||
nodeId: string,
|
||||
version?: string,
|
||||
nodeDefinitionDirs?: string[],
|
||||
discriminators?: { resource?: string; operation?: string; mode?: string },
|
||||
): PathResolutionResult {
|
||||
// Try exact node ID first
|
||||
let result = tryGetNodeFilePath(nodeId, version, nodeDefinitionDirs, discriminators);
|
||||
|
||||
// If not found and node name ends with 'Tool', try base node as fallback
|
||||
// (e.g., n8n-nodes-base.googleCalendarTool -> n8n-nodes-base.googleCalendar)
|
||||
// Note: Some nodes legitimately end in Tool (agentTool, mcpClientTool) but those
|
||||
// have their own type files, so this fallback only triggers when no file is found
|
||||
if (result.error && nodeId.endsWith('Tool')) {
|
||||
const baseNodeId = nodeId.slice(0, -4);
|
||||
result = tryGetNodeFilePath(baseNodeId, version, nodeDefinitionDirs, discriminators);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type definition for a single node ID, optionally for a specific version and discriminators
|
||||
*/
|
||||
function getNodeTypeDefinition(
|
||||
nodeId: string,
|
||||
version?: string,
|
||||
nodeDefinitionDirs?: string[],
|
||||
discriminators?: { resource?: string; operation?: string; mode?: string },
|
||||
): {
|
||||
nodeId: string;
|
||||
version?: string;
|
||||
content: string;
|
||||
availableVersions?: string[];
|
||||
error?: string;
|
||||
} {
|
||||
// Check if any types directory exists
|
||||
const nodesPaths = getGeneratedNodesPaths(nodeDefinitionDirs);
|
||||
const anyDirExists = nodesPaths.some((p) => existsSync(p));
|
||||
if (!anyDirExists) {
|
||||
const errorMsg = nodeDefinitionDirs
|
||||
? 'Node types directory not found in any of the configured dirs. Types may not have been generated yet.'
|
||||
: 'Node types not found. The generated types directory does not exist. Ensure the application has started properly and types have been generated.';
|
||||
return {
|
||||
nodeId,
|
||||
content: '',
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
const pathResult = getNodeFilePath(nodeId, version, nodeDefinitionDirs, discriminators);
|
||||
|
||||
if (pathResult.error) {
|
||||
const availableVersions = getNodeVersions(nodeId, nodeDefinitionDirs);
|
||||
return {
|
||||
nodeId,
|
||||
version,
|
||||
content: '',
|
||||
availableVersions: availableVersions.length > 0 ? availableVersions : undefined,
|
||||
error: pathResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pathResult.filePath) {
|
||||
return {
|
||||
nodeId,
|
||||
content: '',
|
||||
error: `Node type '${nodeId}' not found. Use search_node to find the correct node ID.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(pathResult.filePath, 'utf-8');
|
||||
|
||||
// Extract version from file path - handles both flat (v1.ts) and split (v1/...) structures
|
||||
const actualVersion = pathResult.filePath.match(/\/(v\d+)(?:\/|\.ts)/)?.[1];
|
||||
|
||||
return { nodeId, version: actualVersion, content };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
nodeId,
|
||||
content: '',
|
||||
error: `Error reading node definition for '${nodeId}': ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Node request can be a simple string or an object with optional version and discriminators */
|
||||
type NodeRequest =
|
||||
| string
|
||||
| {
|
||||
nodeId: string;
|
||||
version?: string;
|
||||
// Discriminator parameters for split type files
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for creating the node get tool
|
||||
*/
|
||||
export interface CodeBuilderGetToolOptions {
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the simplified node get tool for code builder
|
||||
* Accepts a list of node IDs (with optional versions) and returns all type definitions in a single call
|
||||
*/
|
||||
export function createCodeBuilderGetTool(options: CodeBuilderGetToolOptions = {}) {
|
||||
const { nodeDefinitionDirs } = options;
|
||||
|
||||
return tool(
|
||||
async (input: { nodeIds: NodeRequest[] }) => {
|
||||
const results: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const nodeRequest of input.nodeIds) {
|
||||
// Support both string and object formats
|
||||
const nodeId = typeof nodeRequest === 'string' ? nodeRequest : nodeRequest.nodeId;
|
||||
const version = typeof nodeRequest === 'string' ? undefined : nodeRequest.version;
|
||||
|
||||
// Extract discriminators from object format
|
||||
const discriminators =
|
||||
typeof nodeRequest === 'string'
|
||||
? undefined
|
||||
: {
|
||||
resource: nodeRequest.resource,
|
||||
operation: nodeRequest.operation,
|
||||
mode: nodeRequest.mode,
|
||||
};
|
||||
|
||||
const result = getNodeTypeDefinition(nodeId, version, nodeDefinitionDirs, discriminators);
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
} else {
|
||||
const versionLabel = result.version ? ` (${result.version})` : '';
|
||||
results.push(
|
||||
`## ${nodeId}${versionLabel}\n\n\`\`\`typescript\n${result.content}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let response = '';
|
||||
|
||||
if (results.length > 0) {
|
||||
response += `# TypeScript Type Definitions\n\n${results.join('\n\n---\n\n')}`;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
response += `\n\n# Errors\n\n${errors.join('\n')}`;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
{
|
||||
name: 'get_node_types',
|
||||
description:
|
||||
'Get the full TypeScript type definitions for one or more nodes. Returns the complete type information including parameters, credentials, and node type variants. By default returns the latest version. For nodes with resource/operation or mode discriminators, you MUST specify them. Use search_nodes first to discover available discriminators. ALWAYS call this with ALL node types you plan to use BEFORE generating workflow code.',
|
||||
schema: z.object({
|
||||
nodeIds: z
|
||||
.array(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
nodeId: z.string().describe('The node ID (e.g., "n8n-nodes-base.httpRequest")'),
|
||||
version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional version (e.g., "34" for v34). Omit for latest version.'),
|
||||
resource: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Resource discriminator for REST API nodes (e.g., "ticket", "contact")',
|
||||
),
|
||||
operation: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Operation discriminator (e.g., "get", "create", "update")'),
|
||||
mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Mode discriminator for nodes like Code (e.g., "runOnceForAllItems")'),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.describe(
|
||||
'Array of nodes to fetch. Can be simple strings for flat nodes (e.g., ["n8n-nodes-base.aggregate"]) or objects with discriminators for split nodes (e.g., [{ nodeId: "n8n-nodes-base.freshservice", resource: "ticket", operation: "get" }] or [{ nodeId: "n8n-nodes-base.code", mode: "runOnceForAllItems" }]). Use search_nodes to discover which nodes require discriminators.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Simplified Node Search Tool for CodeWorkflowBuilder
|
||||
*
|
||||
* Provides a simpler search interface optimized for the code builder.
|
||||
* Searches nodes by name/description without the complex query structure
|
||||
* of the multi-agent system's search tool.
|
||||
*
|
||||
* Includes discriminator information (resource/operation, mode) so the agent
|
||||
* knows what parameters to pass to get_nodes for split type files.
|
||||
*/
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { IParameterBuilderHint, IRelatedNode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
extractResourceOperations,
|
||||
type ResourceInfo,
|
||||
type OperationInfo,
|
||||
} from '../../utils/resource-operation-extractor';
|
||||
import {
|
||||
extractModeDiscriminator,
|
||||
extractOperationOnlyDiscriminator,
|
||||
type ModeInfo,
|
||||
} from '../utils/discriminator-utils';
|
||||
import type { NodeTypeParser, ParsedNodeType } from '../utils/node-type-parser';
|
||||
|
||||
/**
|
||||
* Trigger node types that don't have "trigger" in their name
|
||||
* but still function as workflow entry points
|
||||
*/
|
||||
const TRIGGER_NODE_TYPES = new Set([
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.cron', // Legacy schedule trigger
|
||||
'n8n-nodes-base.emailReadImap', // Email polling trigger
|
||||
'n8n-nodes-base.telegramBot', // Can act as webhook trigger
|
||||
'n8n-nodes-base.start', // Legacy trigger
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a node type is a trigger
|
||||
*/
|
||||
function isTriggerNodeType(type: string): boolean {
|
||||
if (TRIGGER_NODE_TYPES.has(type)) {
|
||||
return true;
|
||||
}
|
||||
return type.toLowerCase().includes('trigger');
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified operation info for discriminator display
|
||||
*/
|
||||
interface DiscriminatorOperationInfo {
|
||||
value: string;
|
||||
description?: string;
|
||||
builderHint?: IParameterBuilderHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified resource info for discriminator display
|
||||
*/
|
||||
interface DiscriminatorResourceInfo {
|
||||
value: string;
|
||||
description?: string;
|
||||
builderHint?: IParameterBuilderHint;
|
||||
operations: DiscriminatorOperationInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator info structure for search results
|
||||
*/
|
||||
interface DiscriminatorInfo {
|
||||
type: 'resource_operation' | 'operation' | 'mode' | 'none';
|
||||
resources?: DiscriminatorResourceInfo[];
|
||||
operations?: DiscriminatorOperationInfo[];
|
||||
modes?: ModeInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format builder hint for a node by reading from its node type definition
|
||||
*/
|
||||
function formatBuilderHint(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
nodeId: string,
|
||||
version: number,
|
||||
): string {
|
||||
const nodeType = nodeTypeParser.getNodeType(nodeId, version);
|
||||
const hint = nodeType?.builderHint?.message;
|
||||
if (!hint) return '';
|
||||
return ` @builderHint ${hint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct related node IDs for a node from its builderHint.relatedNodes.
|
||||
*/
|
||||
function getDirectRelatedNodeIds(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
nodeId: string,
|
||||
version: number,
|
||||
): string[] {
|
||||
const nodeType = nodeTypeParser.getNodeType(nodeId, version);
|
||||
const relatedNodes = nodeType?.builderHint?.relatedNodes;
|
||||
if (!relatedNodes) return [];
|
||||
|
||||
return relatedNodes.map((r) => r.nodeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related nodes with their hints.
|
||||
*/
|
||||
function getRelatedNodesWithHints(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
nodeId: string,
|
||||
version: number,
|
||||
): IRelatedNode[] | undefined {
|
||||
const nodeType = nodeTypeParser.getNodeType(nodeId, version);
|
||||
return nodeType?.builderHint?.relatedNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format related nodes with hints for display.
|
||||
* Returns formatted string or empty string if no related nodes with hints.
|
||||
*/
|
||||
function formatRelatedNodesWithHints(relatedNodes: IRelatedNode[]): string {
|
||||
if (relatedNodes.length === 0) return '';
|
||||
|
||||
const lines = [' @relatedNodes'];
|
||||
for (const related of relatedNodes) {
|
||||
lines.push(` - ${related.nodeType}: "${related.relationHint}"`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all related nodes for a set of node IDs.
|
||||
* Uses a visited set to prevent infinite recursion from circular references.
|
||||
*/
|
||||
function collectAllRelatedNodeIds(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
initialNodeIds: Array<{ id: string; version: number }>,
|
||||
excludeNodeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const allRelated = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Add initial nodes to visited to avoid re-processing them
|
||||
for (const node of initialNodeIds) {
|
||||
visited.add(node.id);
|
||||
}
|
||||
|
||||
// Also mark excluded nodes as visited
|
||||
for (const id of excludeNodeIds) {
|
||||
visited.add(id);
|
||||
}
|
||||
|
||||
// Process queue of nodes to check for related nodes
|
||||
const queue: Array<{ id: string; version: number }> = [...initialNodeIds];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const relatedIds = getDirectRelatedNodeIds(nodeTypeParser, current.id, current.version);
|
||||
|
||||
for (const relatedId of relatedIds) {
|
||||
if (visited.has(relatedId)) {
|
||||
continue; // Already processed or excluded
|
||||
}
|
||||
|
||||
visited.add(relatedId);
|
||||
allRelated.add(relatedId);
|
||||
|
||||
// Get the related node's version and add to queue for recursive processing
|
||||
const relatedNodeType = nodeTypeParser.getNodeType(relatedId);
|
||||
if (relatedNodeType) {
|
||||
const relatedVersion = Array.isArray(relatedNodeType.version)
|
||||
? relatedNodeType.version[relatedNodeType.version.length - 1]
|
||||
: relatedNodeType.version;
|
||||
queue.push({ id: relatedId, version: relatedVersion });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allRelated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps NodeConnectionType to SDK function and subnode field
|
||||
*/
|
||||
const CONNECTION_TYPE_TO_SDK: Record<string, { fn: string; subnodeField: string }> = {
|
||||
ai_tool: { fn: 'tool()', subnodeField: 'subnodes.tools' },
|
||||
ai_vectorStore: { fn: 'vectorStore()', subnodeField: 'subnodes.vectorStore' },
|
||||
ai_retriever: { fn: 'retriever()', subnodeField: 'subnodes.retriever' },
|
||||
ai_languageModel: { fn: 'languageModel()', subnodeField: 'subnodes.model' },
|
||||
ai_memory: { fn: 'memory()', subnodeField: 'subnodes.memory' },
|
||||
ai_outputParser: { fn: 'outputParser()', subnodeField: 'subnodes.outputParser' },
|
||||
ai_embedding: { fn: 'embeddings()', subnodeField: 'subnodes.embeddings' },
|
||||
ai_document: { fn: 'documentLoader()', subnodeField: 'subnodes.documentLoader' },
|
||||
ai_textSplitter: { fn: 'textSplitter()', subnodeField: 'subnodes.textSplitter' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Display name overrides for confusing mode names
|
||||
* Maps mode value -> original display name -> clearer display name
|
||||
*/
|
||||
const MODE_DISPLAY_NAME_OVERRIDES: Record<string, Record<string, string>> = {
|
||||
retrieve: {
|
||||
'Retrieve Documents (As Vector Store for Chain/Tool)':
|
||||
'Retrieve Documents (As Vector Store for Chain)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a mode for display, including SDK function mapping only if showSdkMapping is true
|
||||
*/
|
||||
function formatModeForDisplay(mode: ModeInfo, showSdkMapping: boolean): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Apply display name override if available (to remove confusing text)
|
||||
const displayName =
|
||||
MODE_DISPLAY_NAME_OVERRIDES[mode.value]?.[mode.displayName] ?? mode.displayName;
|
||||
|
||||
// First line: value and display name
|
||||
let firstLine = ` - ${mode.value}: "${displayName}"`;
|
||||
|
||||
// Add SDK mapping if applicable
|
||||
if (showSdkMapping) {
|
||||
const sdkMapping = mode.outputConnectionType
|
||||
? CONNECTION_TYPE_TO_SDK[mode.outputConnectionType]
|
||||
: undefined;
|
||||
|
||||
if (sdkMapping) {
|
||||
// Include mode parameter in the SDK function call for clarity
|
||||
const fnWithMode = sdkMapping.fn.replace('()', `({ mode: '${mode.value}' })`);
|
||||
firstLine += ` → use ${fnWithMode} for ${sdkMapping.subnodeField}`;
|
||||
} else {
|
||||
firstLine += ' → use node()';
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(firstLine);
|
||||
|
||||
// Add description if available
|
||||
if (mode.description) {
|
||||
lines.push(` ${mode.description}`);
|
||||
}
|
||||
|
||||
// Add builder hint if available
|
||||
if (mode.builderHint) {
|
||||
lines.push(` @builderHint ${mode.builderHint.message}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract discriminator info from a node type
|
||||
* Returns resource/operation, mode, or none
|
||||
*/
|
||||
function getDiscriminatorInfo(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
nodeId: string,
|
||||
version: number,
|
||||
): DiscriminatorInfo {
|
||||
const nodeType = nodeTypeParser.getNodeType(nodeId, version);
|
||||
if (!nodeType) {
|
||||
return { type: 'none' };
|
||||
}
|
||||
|
||||
// Check for resource/operation pattern
|
||||
// Include description and builderHint for code-builder's detailed output
|
||||
const resourceOps = extractResourceOperations(nodeType, version, undefined, {
|
||||
fields: { description: true, builderHint: true },
|
||||
});
|
||||
if (resourceOps && resourceOps.resources.length > 0) {
|
||||
const resources: DiscriminatorResourceInfo[] = resourceOps.resources
|
||||
.filter((r: ResourceInfo) => r.value !== '__CUSTOM_API_CALL__')
|
||||
.map((r: ResourceInfo) => ({
|
||||
value: r.value,
|
||||
description: r.description,
|
||||
builderHint: r.builderHint,
|
||||
operations: r.operations
|
||||
.filter((op: OperationInfo) => op.value !== '__CUSTOM_API_CALL__')
|
||||
.map((op: OperationInfo) => ({
|
||||
value: op.value,
|
||||
description: op.description,
|
||||
builderHint: op.builderHint,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (resources.length > 0) {
|
||||
return { type: 'resource_operation', resources };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for operation-only pattern (operation without resource)
|
||||
const operationOnly = extractOperationOnlyDiscriminator(nodeType, version);
|
||||
if (operationOnly && operationOnly.operations.length > 0) {
|
||||
const operations: DiscriminatorOperationInfo[] = operationOnly.operations
|
||||
.filter((op) => op.value !== '__CUSTOM_API_CALL__')
|
||||
.map((op) => ({
|
||||
value: op.value,
|
||||
description: op.description,
|
||||
builderHint: op.builderHint,
|
||||
}));
|
||||
|
||||
if (operations.length > 0) {
|
||||
return { type: 'operation', operations };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mode pattern
|
||||
const modeInfo = extractModeDiscriminator(nodeType, version);
|
||||
if (modeInfo && modeInfo.modes.length > 0) {
|
||||
return { type: 'mode', modes: modeInfo.modes };
|
||||
}
|
||||
|
||||
return { type: 'none' };
|
||||
}
|
||||
|
||||
function formatResourceOperationLines(
|
||||
resources: DiscriminatorResourceInfo[],
|
||||
nodeId: string,
|
||||
): string[] {
|
||||
const lines: string[] = [' resource:'];
|
||||
for (const resource of resources) {
|
||||
lines.push(` - ${resource.value}:`);
|
||||
if (resource.description) {
|
||||
lines.push(` ${resource.description}`);
|
||||
}
|
||||
if (resource.builderHint) {
|
||||
lines.push(` @builderHint ${resource.builderHint.message}`);
|
||||
}
|
||||
|
||||
lines.push(' operations:');
|
||||
for (const op of resource.operations) {
|
||||
lines.push(` - ${op.value}`);
|
||||
if (op.description) {
|
||||
lines.push(` ${op.description}`);
|
||||
}
|
||||
if (op.builderHint) {
|
||||
lines.push(` @builderHint ${op.builderHint.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const firstResource = resources[0];
|
||||
const firstOp = firstResource?.operations[0]?.value || 'get';
|
||||
lines.push('');
|
||||
lines.push(' Use get_node_types with discriminators:');
|
||||
lines.push(
|
||||
` get_node_types({ nodeIds: [{ nodeId: "${nodeId}", resource: "${firstResource?.value}", operation: "${firstOp}" }] })`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatOperationLines(operations: DiscriminatorOperationInfo[], nodeId: string): string[] {
|
||||
const lines: string[] = [' operation:'];
|
||||
for (const op of operations) {
|
||||
lines.push(` - ${op.value}`);
|
||||
if (op.description) {
|
||||
lines.push(` ${op.description}`);
|
||||
}
|
||||
if (op.builderHint) {
|
||||
lines.push(` @builderHint ${op.builderHint.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const firstOp = operations[0]?.value ?? 'default';
|
||||
lines.push('');
|
||||
lines.push(' Use get_node_types with discriminators:');
|
||||
lines.push(` get_node_types({ nodeIds: [{ nodeId: "${nodeId}", operation: "${firstOp}" }] })`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatModeLines(modes: ModeInfo[], nodeId: string): string[] {
|
||||
const lines: string[] = [' mode:'];
|
||||
const hasSubnodeModes = modes.some((m) => m.outputConnectionType);
|
||||
for (const mode of modes) {
|
||||
lines.push(formatModeForDisplay(mode, hasSubnodeModes));
|
||||
}
|
||||
|
||||
const firstMode = modes[0];
|
||||
lines.push('');
|
||||
lines.push(' Use get_node_types with discriminators:');
|
||||
lines.push(
|
||||
` get_node_types({ nodeIds: [{ nodeId: "${nodeId}", mode: "${firstMode.value}" }] })`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format discriminator info for display in search results
|
||||
*/
|
||||
function formatDiscriminatorInfo(info: DiscriminatorInfo, nodeId: string): string {
|
||||
if (info.type === 'none') {
|
||||
return ' Discriminators: none (use node directly without resource/operation/mode)';
|
||||
}
|
||||
|
||||
const lines: string[] = [' Discriminators:'];
|
||||
|
||||
if (info.type === 'resource_operation' && info.resources) {
|
||||
lines.push(...formatResourceOperationLines(info.resources, nodeId));
|
||||
} else if (info.type === 'operation' && info.operations) {
|
||||
lines.push(...formatOperationLines(info.operations, nodeId));
|
||||
} else if (info.type === 'mode' && info.modes) {
|
||||
lines.push(...formatModeLines(info.modes, nodeId));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the simplified node search tool for code builder
|
||||
* Accepts multiple queries and returns separate results for each
|
||||
* Includes discriminator information for nodes with resource/operation or mode patterns
|
||||
*/
|
||||
export function createCodeBuilderSearchTool(nodeTypeParser: NodeTypeParser) {
|
||||
return tool(
|
||||
async (input: { queries: string[] }) => {
|
||||
const allResults: string[] = [];
|
||||
|
||||
for (const query of input.queries) {
|
||||
const results = nodeTypeParser.searchNodeTypes(query, 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
allResults.push(`## "${query}"\nNo nodes found. Try a different search term.`);
|
||||
} else {
|
||||
// Track which node IDs have been shown to avoid duplicates
|
||||
const shownNodeIds = new Set<string>(results.map((node: ParsedNodeType) => node.id));
|
||||
|
||||
const allNodeLines: string[] = [];
|
||||
let totalRelatedCount = 0;
|
||||
|
||||
for (const node of results) {
|
||||
// Format the search result node
|
||||
const triggerTag = node.isTrigger ? ' [TRIGGER]' : '';
|
||||
const basicInfo = `- ${node.id}${triggerTag}\n Display Name: ${node.displayName}\n Version: ${node.version}\n Description: ${node.description}`;
|
||||
|
||||
// Get builder hint
|
||||
const builderHint = formatBuilderHint(nodeTypeParser, node.id, node.version);
|
||||
|
||||
// Check for new relatedNodes format with hints
|
||||
const relatedNodesWithHints = getRelatedNodesWithHints(
|
||||
nodeTypeParser,
|
||||
node.id,
|
||||
node.version,
|
||||
);
|
||||
|
||||
// Get discriminator info
|
||||
const discInfo = getDiscriminatorInfo(nodeTypeParser, node.id, node.version);
|
||||
const discStr = formatDiscriminatorInfo(discInfo, node.id);
|
||||
|
||||
const parts = [basicInfo];
|
||||
if (builderHint) parts.push(builderHint);
|
||||
|
||||
// If using new format with hints, display @relatedNodes section instead of expanding
|
||||
if (relatedNodesWithHints && relatedNodesWithHints.length > 0) {
|
||||
const relatedNodesStr = formatRelatedNodesWithHints(relatedNodesWithHints);
|
||||
if (relatedNodesStr) parts.push(relatedNodesStr);
|
||||
} else {
|
||||
// Legacy format: expand related nodes as [RELATED] entries
|
||||
const relatedNodeIds = collectAllRelatedNodeIds(
|
||||
nodeTypeParser,
|
||||
[{ id: node.id, version: node.version }],
|
||||
shownNodeIds,
|
||||
);
|
||||
|
||||
// Add related nodes immediately after their parent search result
|
||||
// First, add discriminator info to current node
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
|
||||
for (const relatedId of relatedNodeIds) {
|
||||
const nodeType = nodeTypeParser.getNodeType(relatedId);
|
||||
if (nodeType) {
|
||||
const version = Array.isArray(nodeType.version)
|
||||
? nodeType.version[nodeType.version.length - 1]
|
||||
: nodeType.version;
|
||||
const relatedTriggerTag = isTriggerNodeType(relatedId) ? ' [TRIGGER]' : '';
|
||||
const relatedBasicInfo = `- ${relatedId}${relatedTriggerTag} [RELATED]\n Display Name: ${nodeType.displayName}\n Version: ${version}\n Description: ${nodeType.description}`;
|
||||
|
||||
// Get builder hint for related node too
|
||||
const relatedBuilderHint = formatBuilderHint(nodeTypeParser, relatedId, version);
|
||||
|
||||
// Get discriminator info for related node
|
||||
const relatedDiscInfo = getDiscriminatorInfo(nodeTypeParser, relatedId, version);
|
||||
const relatedDiscStr = formatDiscriminatorInfo(relatedDiscInfo, relatedId);
|
||||
|
||||
const relatedParts = [relatedBasicInfo];
|
||||
if (relatedBuilderHint) relatedParts.push(relatedBuilderHint);
|
||||
if (relatedDiscStr) relatedParts.push(relatedDiscStr);
|
||||
|
||||
allNodeLines.push(relatedParts.join('\n'));
|
||||
|
||||
// Mark as shown to prevent duplicates
|
||||
shownNodeIds.add(relatedId);
|
||||
totalRelatedCount++;
|
||||
}
|
||||
}
|
||||
continue; // Skip the common push below since we handled it in the legacy branch
|
||||
}
|
||||
|
||||
if (discStr) parts.push(discStr);
|
||||
allNodeLines.push(parts.join('\n'));
|
||||
}
|
||||
|
||||
const countSuffix = totalRelatedCount > 0 ? ` (+ ${totalRelatedCount} related)` : '';
|
||||
|
||||
allResults.push(
|
||||
`## "${query}"\nFound ${results.length} nodes${countSuffix}:\n\n${allNodeLines.join('\n\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = allResults.join('\n\n---\n\n');
|
||||
|
||||
return response;
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description:
|
||||
'Search for n8n nodes by name or service. Accepts multiple search queries and returns separate result lists for each. Use this when you need to find nodes for specific integrations or services (e.g., ["salesforce", "http", "gmail"]).',
|
||||
schema: z.object({
|
||||
queries: z
|
||||
.array(z.string())
|
||||
.describe('Array of search queries (e.g., ["salesforce", "http", "gmail"])'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
suggestedNodesData,
|
||||
categoryList,
|
||||
type CategorySuggestedNode,
|
||||
} from './suggested-nodes-data';
|
||||
import type { NodeTypeParser } from '../utils/node-type-parser';
|
||||
|
||||
const GetSuggestedNodesSchema = z.object({
|
||||
categories: z
|
||||
.array(z.string())
|
||||
.describe('List of workflow technique categories to get node suggestions for'),
|
||||
});
|
||||
|
||||
type GetSuggestedNodesInput = z.infer<typeof GetSuggestedNodesSchema>;
|
||||
|
||||
interface FormattedCategoryResult {
|
||||
category: string;
|
||||
patternHint: string;
|
||||
nodes: Array<{
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
note?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function formatCategoryResult(
|
||||
nodeTypeParser: NodeTypeParser,
|
||||
category: string,
|
||||
): FormattedCategoryResult | null {
|
||||
const categoryData = suggestedNodesData[category];
|
||||
if (!categoryData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = categoryData.nodes.map((node: CategorySuggestedNode) => {
|
||||
const nodeType = nodeTypeParser.getNodeType(node.name);
|
||||
if (nodeType) {
|
||||
return {
|
||||
name: node.name,
|
||||
displayName: nodeType.displayName,
|
||||
description: nodeType.description,
|
||||
note: node.note,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: node.name,
|
||||
displayName: '(not found)',
|
||||
description: '(not found)',
|
||||
note: node.note,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
category,
|
||||
patternHint: categoryData.patternHint,
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOutput(results: Array<FormattedCategoryResult | null>): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
lines.push('Category not found\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(`## ${result.category}`);
|
||||
lines.push(`patternHint: ${result.patternHint}`);
|
||||
lines.push('');
|
||||
lines.push('Suggested nodes:');
|
||||
|
||||
for (const node of result.nodes) {
|
||||
lines.push(`- ${node.name}`);
|
||||
lines.push(` displayName: ${node.displayName}`);
|
||||
lines.push(` description: ${node.description}`);
|
||||
if (node.note) {
|
||||
lines.push(` note: ${node.note}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createGetSuggestedNodesTool(nodeTypeParser: NodeTypeParser) {
|
||||
return new DynamicStructuredTool({
|
||||
name: 'get_suggested_nodes',
|
||||
description: `Returns curated node recommendations by workflow technique category.
|
||||
|
||||
Available categories: ${categoryList.join(', ')}`,
|
||||
schema: GetSuggestedNodesSchema,
|
||||
func: async (input: GetSuggestedNodesInput): Promise<string> => {
|
||||
const results: Array<FormattedCategoryResult | null> = [];
|
||||
|
||||
for (const category of input.categories) {
|
||||
const result = formatCategoryResult(nodeTypeParser, category);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
} else {
|
||||
// Unknown category - return error message
|
||||
results.push({
|
||||
category,
|
||||
patternHint: 'Category not found',
|
||||
nodes: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return formatOutput(results);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Data for get_suggested_nodes tool.
|
||||
* Contains curated node recommendations organized by workflow technique category.
|
||||
*/
|
||||
|
||||
export interface CategorySuggestedNode {
|
||||
name: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CategoryData {
|
||||
description: string;
|
||||
patternHint: string;
|
||||
nodes: CategorySuggestedNode[];
|
||||
}
|
||||
|
||||
export const suggestedNodesData: Record<string, CategoryData> = {
|
||||
chatbot: {
|
||||
description: 'Receiving chat messages and replying (built-in chat, Telegram, Slack, etc.)',
|
||||
patternHint: 'Chat Trigger → AI Agent → Memory → Response',
|
||||
nodes: [
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
note: 'When loadPreviousSession is set to memory, the downstream Agent must also have its own memory subnode to maintain conversation context during processing',
|
||||
},
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
note: 'Every agent in a conversational workflow MUST have a memory subnode connected. If multiple agents share the same conversation, they must use the same memory session key',
|
||||
},
|
||||
{ name: '@n8n/n8n-nodes-langchain.lmChatOpenAi' },
|
||||
{ name: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' },
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
note: 'Maintains short-term conversation history. Must be connected as a subnode to every Agent that participates in a conversation. When multiple agents share a conversation, use the same session key across all of them',
|
||||
},
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.retrieverVectorStore',
|
||||
note: 'Connects any Vector Store (Pinecone, Qdrant, Supabase, In-Memory, etc.) to an AI Agent for RAG. Use this as a subnode between the vector store and the agent to retrieve relevant documents when answering questions',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.slack' },
|
||||
{ name: 'n8n-nodes-base.telegram' },
|
||||
{ name: 'n8n-nodes-base.whatsApp' },
|
||||
{ name: 'n8n-nodes-base.discord' },
|
||||
],
|
||||
},
|
||||
|
||||
notification: {
|
||||
description: 'Sending alerts or updates via email, chat, SMS when events occur',
|
||||
patternHint: 'Trigger → Condition → Send (Email/Slack/SMS)',
|
||||
nodes: [
|
||||
{ name: 'n8n-nodes-base.webhook', note: 'Event-based notifications from external systems' },
|
||||
{
|
||||
name: 'n8n-nodes-base.scheduleTrigger',
|
||||
note: 'Periodic monitoring and batch notifications',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.gmail',
|
||||
note: "Default to this because it's easy for users to setup",
|
||||
},
|
||||
{ name: 'n8n-nodes-base.slack' },
|
||||
{ name: 'n8n-nodes-base.telegram' },
|
||||
{ name: 'n8n-nodes-base.twilio' },
|
||||
{
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
note: 'For services without dedicated nodes (Teams, Discord)',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.if', note: 'Check alert conditions before sending' },
|
||||
{
|
||||
name: 'n8n-nodes-base.switch',
|
||||
note: 'If routing by severity/type is needed, use Switch to direct to different channels',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
scheduling: {
|
||||
description: 'Running actions at specific times or intervals',
|
||||
patternHint: 'Schedule Trigger → Fetch → Process → Act',
|
||||
nodes: [
|
||||
{ name: 'n8n-nodes-base.scheduleTrigger' },
|
||||
{ name: 'n8n-nodes-base.httpRequest' },
|
||||
{ name: 'n8n-nodes-base.set' },
|
||||
{ name: 'n8n-nodes-base.wait', note: 'Respect rate limits between API calls' },
|
||||
],
|
||||
},
|
||||
|
||||
data_transformation: {
|
||||
description: 'Cleaning, formatting, or restructuring data',
|
||||
patternHint: 'Input → Filter/Map → Transform → Output',
|
||||
nodes: [
|
||||
{ name: 'n8n-nodes-base.set' },
|
||||
{ name: 'n8n-nodes-base.if', note: 'Use early to validate inputs' },
|
||||
{ name: 'n8n-nodes-base.filter', note: 'Use early to reduce data volume' },
|
||||
{ name: 'n8n-nodes-base.summarize', note: 'Pivot table-style aggregations' },
|
||||
{ name: 'n8n-nodes-base.aggregate', note: 'Combine multiple items into one' },
|
||||
{
|
||||
name: 'n8n-nodes-base.splitOut',
|
||||
note: 'Convert single item with array into multiple items',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.sort' },
|
||||
{ name: 'n8n-nodes-base.limit' },
|
||||
{ name: 'n8n-nodes-base.removeDuplicates' },
|
||||
{
|
||||
name: 'n8n-nodes-base.splitInBatches',
|
||||
note: 'For large datasets (100+ items), batch processing prevents timeouts',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
data_persistence: {
|
||||
description: 'Storing, updating, or retrieving records from persistent storage',
|
||||
patternHint: 'Trigger → Process → Store (DataTable/Sheets)',
|
||||
nodes: [
|
||||
{ name: 'n8n-nodes-base.dataTable', note: 'PREFERRED - no external config needed' },
|
||||
{
|
||||
name: 'n8n-nodes-base.googleSheets',
|
||||
note: 'For collaboration needs; if >10k rows expected, consider DataTable instead',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.airtable',
|
||||
note: 'If relationships between tables are needed',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.postgres' },
|
||||
{ name: 'n8n-nodes-base.mySql' },
|
||||
{ name: 'n8n-nodes-base.mongoDb' },
|
||||
],
|
||||
},
|
||||
|
||||
data_extraction: {
|
||||
description: 'Pulling specific information from structured or unstructured inputs',
|
||||
patternHint: 'Source → Extract → Parse → Structure',
|
||||
nodes: [
|
||||
{
|
||||
name: 'n8n-nodes-base.extractFromFile',
|
||||
note: 'For multiple file types, route by file type first with IF/Switch',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.htmlExtract', note: 'JS-rendered content may be empty' },
|
||||
{ name: 'n8n-nodes-base.splitOut', note: 'Use before Loop Over Items for arrays' },
|
||||
{
|
||||
name: 'n8n-nodes-base.splitInBatches',
|
||||
note: 'Process 200 rows at a time for memory',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.code' },
|
||||
{ name: '@n8n/n8n-nodes-langchain.informationExtractor', note: 'For unstructured text' },
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.chainSummarization',
|
||||
note: 'Context window limits may truncate',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
document_processing: {
|
||||
description: 'Taking action on content within files (PDFs, Word docs, images)',
|
||||
patternHint: 'Trigger → Extract Text → AI Parse → Store',
|
||||
nodes: [
|
||||
{
|
||||
name: 'n8n-nodes-base.gmailTrigger',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.googleDriveTrigger' },
|
||||
{
|
||||
name: 'n8n-nodes-base.extractFromFile',
|
||||
note: 'Different file types require different operations - route accordingly',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.awsTextract', note: 'For tables and forms in scanned docs' },
|
||||
{ name: 'n8n-nodes-base.mindee', note: 'Specialized invoice/receipt parsing' },
|
||||
{ name: '@n8n/n8n-nodes-langchain.agent' },
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.documentDefaultDataLoader',
|
||||
note: 'Loads binary files (PDF, CSV, JSON, DOCX, EPUB, text) into LangChain Documents. Auto-detects format from MIME type. Requires a preceding node that outputs binary data',
|
||||
},
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.vectorStoreInMemory',
|
||||
note: 'No external dependencies needed',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.splitInBatches', note: 'Process 5-10 files at a time' },
|
||||
],
|
||||
},
|
||||
|
||||
form_input: {
|
||||
description: 'Gathering data from users via forms',
|
||||
patternHint: 'Form Trigger → Validate → Store → Respond',
|
||||
nodes: [
|
||||
{ name: 'n8n-nodes-base.formTrigger', note: 'ALWAYS store raw data to persistent storage' },
|
||||
{ name: 'n8n-nodes-base.form', note: 'Each node is one page/step' },
|
||||
{ name: 'n8n-nodes-base.dataTable', note: 'PREFERRED for form data storage' },
|
||||
{ name: 'n8n-nodes-base.googleSheets' },
|
||||
{ name: 'n8n-nodes-base.airtable' },
|
||||
],
|
||||
},
|
||||
|
||||
content_generation: {
|
||||
description: 'Creating text, images, audio, or video',
|
||||
patternHint: 'Trigger → Generate (Text/Image/Video) → Deliver',
|
||||
nodes: [
|
||||
{ name: '@n8n/n8n-nodes-langchain.agent', note: 'For text generation' },
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.openAi',
|
||||
note: 'Use for image/video generation. DALL-E, TTS, Sora video generation',
|
||||
},
|
||||
{ name: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', note: 'Imagen, video generation' },
|
||||
{ name: 'n8n-nodes-base.httpRequest', note: 'For APIs without dedicated nodes' },
|
||||
{ name: 'n8n-nodes-base.editImage', note: 'Resize, crop, format conversion' },
|
||||
{ name: 'n8n-nodes-base.markdown', note: 'Convert to HTML' },
|
||||
{ name: 'n8n-nodes-base.facebookGraphApi' },
|
||||
{
|
||||
name: 'n8n-nodes-base.wait',
|
||||
note: 'Video generation is async, use wait while polling for updated',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
triage: {
|
||||
description: 'Classifying data for routing or prioritization',
|
||||
patternHint: 'Trigger → Classify → Route → Act',
|
||||
nodes: [
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
note: 'For consistent/deterministic classification, always use structured output parser and set temperature 0-0.2',
|
||||
},
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
note: 'Critical to ensure agent output is consistent and matching general schema',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
scraping_and_research: {
|
||||
description: 'Collecting information from websites or APIs',
|
||||
patternHint: 'Trigger → Fetch → Extract → Store',
|
||||
nodes: [
|
||||
{
|
||||
name: 'n8n-nodes-base.dataTable',
|
||||
note: 'Default storage for scraped data when the user does not specify a destination. No external config needed. Always include a storage step in scraping workflows',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.phantombuster',
|
||||
note: 'Use this for social media requests: LinkedIn, Facebook, Instagram, Twitter, etc.',
|
||||
},
|
||||
{
|
||||
name: '@n8n/n8n-nodes-langchain.toolSerpApi',
|
||||
note: 'Give agent web search capability, get up-to-date information from websites.',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.perplexity', note: 'Recommended for fetching up-to-date news' },
|
||||
{ name: 'n8n-nodes-base.perplexityTool', note: 'Recommended for fetching up-to-date news' },
|
||||
{
|
||||
name: 'n8n-nodes-base.htmlExtract',
|
||||
note: 'Use to extract HTML content from http requests. Though, JS-rendered sites may return empty',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.splitInBatches',
|
||||
note: 'Use to batch the processing of items. General recommendation: 200 rows at a time if processing is fast',
|
||||
},
|
||||
{ name: 'n8n-nodes-base.wait', note: 'Use this to avoid rate limits (429 errors)' },
|
||||
{ name: 'n8n-nodes-base.httpRequest' },
|
||||
{ name: 'n8n-nodes-base.httpRequestTool' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const categoryList = Object.keys(suggestedNodesData);
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import {
|
||||
createCodeBuilderGetTool,
|
||||
isValidPathComponent,
|
||||
validatePathWithinBase,
|
||||
} from '../code-builder-get.tool';
|
||||
|
||||
describe('CodeBuilderGetTool', () => {
|
||||
describe('createCodeBuilderGetTool', () => {
|
||||
it('should create tool without throwing when nodeDefinitionDirs is not provided', () => {
|
||||
// This test verifies that the tool can be created without errors
|
||||
// when no custom nodeDefinitionDirs is provided.
|
||||
expect(() => createCodeBuilderGetTool()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create tool with custom nodeDefinitionDirs', () => {
|
||||
expect(() => createCodeBuilderGetTool({ nodeDefinitionDirs: ['/tmp/test'] })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return a tool with correct name', () => {
|
||||
const tool = createCodeBuilderGetTool();
|
||||
expect(tool.name).toBe('get_node_types');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool invocation', () => {
|
||||
it('should invoke without package.json resolution errors', async () => {
|
||||
// This test verifies that invoking the tool doesn't throw the error:
|
||||
// "Package subpath './package.json' is not defined by 'exports'"
|
||||
// The tool should handle the fallback path resolution gracefully.
|
||||
const tool = createCodeBuilderGetTool();
|
||||
|
||||
// Invoke with a non-existent node - we expect an error message in the response,
|
||||
// but NOT a thrown exception related to package.json resolution.
|
||||
const result = await tool.invoke({ nodeIds: ['n8n-nodes-base.nonExistentNode'] });
|
||||
|
||||
// The result should be a string (error message about node not found)
|
||||
// NOT a thrown error about package.json exports
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('not found');
|
||||
});
|
||||
|
||||
it('should provide helpful error when generated types directory does not exist', async () => {
|
||||
// When a non-existent nodeDefinitionDirs is provided, the error message
|
||||
// should indicate that the types need to be generated.
|
||||
const tool = createCodeBuilderGetTool({
|
||||
nodeDefinitionDirs: ['/non-existent-path-that-does-not-exist-12345'],
|
||||
});
|
||||
|
||||
// Request a valid node that should exist if types were generated
|
||||
const result = await tool.invoke({ nodeIds: ['n8n-nodes-base.httpRequest'] });
|
||||
|
||||
// Should return a helpful error message about types not being generated
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('discriminator support', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create a temporary directory structure for testing
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'get-nodes-test-'));
|
||||
|
||||
// Create flat version file structure (for nodes without discriminators)
|
||||
const flatNodeDir = path.join(tempDir, 'nodes/n8n-nodes-base/aggregate');
|
||||
fs.mkdirSync(flatNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(flatNodeDir, 'v1.ts'),
|
||||
'export type AggregateV1Config = { fieldName: string };',
|
||||
);
|
||||
fs.writeFileSync(path.join(flatNodeDir, 'index.ts'), "export * from './v1';");
|
||||
|
||||
// Create split version directory structure (for nodes with resource/operation)
|
||||
const splitNodeDir = path.join(tempDir, 'nodes/n8n-nodes-base/freshservice/v1');
|
||||
fs.mkdirSync(splitNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(splitNodeDir, '_shared.ts'),
|
||||
'export interface FreshserviceV1NodeBase { type: string; }',
|
||||
);
|
||||
fs.writeFileSync(path.join(splitNodeDir, 'index.ts'), "export * from './_shared';");
|
||||
|
||||
// Create resource directory
|
||||
const ticketDir = path.join(splitNodeDir, 'resource_ticket');
|
||||
fs.mkdirSync(ticketDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ticketDir, 'operation_get.ts'),
|
||||
"export type FreshserviceV1TicketGetConfig = { resource: 'ticket'; operation: 'get'; ticketId: string };",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ticketDir, 'operation_create.ts'),
|
||||
"export type FreshserviceV1TicketCreateConfig = { resource: 'ticket'; operation: 'create'; subject: string };",
|
||||
);
|
||||
fs.writeFileSync(path.join(ticketDir, 'index.ts'), "export * from './operation_get';");
|
||||
|
||||
// Create mode-based split structure (for Code node)
|
||||
const codeNodeDir = path.join(tempDir, 'nodes/n8n-nodes-base/code/v2');
|
||||
fs.mkdirSync(codeNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codeNodeDir, '_shared.ts'),
|
||||
'export interface CodeV2NodeBase { type: string; }',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codeNodeDir, 'mode_run_once_for_all_items.ts'),
|
||||
"export type CodeV2RunOnceForAllItemsConfig = { mode: 'runOnceForAllItems'; jsCode: string };",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codeNodeDir, 'mode_run_once_for_each_item.ts'),
|
||||
"export type CodeV2RunOnceForEachItemConfig = { mode: 'runOnceForEachItem'; jsCode: string };",
|
||||
);
|
||||
fs.writeFileSync(path.join(codeNodeDir, 'index.ts'), "export * from './_shared';");
|
||||
|
||||
// Create node-level index for freshservice
|
||||
const freshserviceNodeDir = path.join(tempDir, 'nodes/n8n-nodes-base/freshservice');
|
||||
fs.writeFileSync(path.join(freshserviceNodeDir, 'index.ts'), "export * from './v1';");
|
||||
|
||||
// Create node-level index for code
|
||||
const codeBaseDir = path.join(tempDir, 'nodes/n8n-nodes-base/code');
|
||||
fs.writeFileSync(path.join(codeBaseDir, 'index.ts'), "export * from './v2';");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should read flat version file for nodes without discriminators', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.aggregate'],
|
||||
});
|
||||
|
||||
expect(result).toContain('AggregateV1Config');
|
||||
expect(result).toContain('fieldName');
|
||||
});
|
||||
|
||||
it('should read specific operation file when resource and operation provided', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.freshservice',
|
||||
resource: 'ticket',
|
||||
operation: 'get',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('FreshserviceV1TicketGetConfig');
|
||||
expect(result).toContain('ticketId');
|
||||
});
|
||||
|
||||
it('should read specific mode file when mode provided', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.code',
|
||||
mode: 'runOnceForAllItems',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('CodeV2RunOnceForAllItemsConfig');
|
||||
expect(result).toContain('jsCode');
|
||||
});
|
||||
|
||||
it('should return error when split node is requested without discriminators', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.freshservice'],
|
||||
});
|
||||
|
||||
// Should return an error indicating discriminators are required
|
||||
expect(result).toContain('Error');
|
||||
expect(result.toLowerCase()).toMatch(/resource|discriminator|operation/);
|
||||
});
|
||||
|
||||
it('should return error when invalid discriminator value is provided', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.freshservice',
|
||||
resource: 'invalid_resource',
|
||||
operation: 'get',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Should indicate the resource is not valid
|
||||
expect(result).toContain('Error');
|
||||
expect(result.toLowerCase()).toMatch(/invalid|not found|does not exist/);
|
||||
});
|
||||
|
||||
it('should accept string nodeId for backwards compatibility', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
// String nodeId should work for flat files
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.aggregate'],
|
||||
});
|
||||
|
||||
expect(result).toContain('AggregateV1Config');
|
||||
});
|
||||
|
||||
it('should handle mixed array of string and object nodeIds', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
'n8n-nodes-base.aggregate',
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.code',
|
||||
mode: 'runOnceForEachItem',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Should have both results
|
||||
expect(result).toContain('AggregateV1Config');
|
||||
expect(result).toContain('CodeV2RunOnceForEachItemConfig');
|
||||
});
|
||||
|
||||
it('should prefer split structure over flat file when discriminators are provided', async () => {
|
||||
// This test creates a node that has BOTH a flat file (v21.ts) AND a split directory (v21/)
|
||||
// When discriminators are provided, it should use the split structure, not the flat file
|
||||
const openaiDir = path.join(tempDir, 'nodes/n8n-nodes-base/openai');
|
||||
fs.mkdirSync(openaiDir, { recursive: true });
|
||||
|
||||
// Create flat file (v21.ts) - large file like the real OpenAI node
|
||||
fs.writeFileSync(
|
||||
path.join(openaiDir, 'v21.ts'),
|
||||
'export type OpenAIV21FlatConfig = { type: "n8n-nodes-base.openai"; /* This is the FLAT file with 928 lines */ flatFileMarker: true };',
|
||||
);
|
||||
|
||||
// Create split directory (v21/) with resource/operation structure
|
||||
const v21Dir = path.join(openaiDir, 'v21');
|
||||
fs.mkdirSync(v21Dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(v21Dir, '_shared.ts'),
|
||||
'export interface OpenAIV21Base { type: string; }',
|
||||
);
|
||||
|
||||
const videoDir = path.join(v21Dir, 'resource_video');
|
||||
fs.mkdirSync(videoDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(videoDir, 'operation_generate.ts'),
|
||||
'export type OpenAIV21VideoGenerateConfig = { resource: "video"; operation: "generate"; splitFileMarker: true };',
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(openaiDir, 'index.ts'), "export * from './v21';");
|
||||
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.openai',
|
||||
resource: 'video',
|
||||
operation: 'generate',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Should use the split file (69 lines with splitFileMarker), NOT the flat file (928 lines with flatFileMarker)
|
||||
expect(result).toContain('splitFileMarker');
|
||||
expect(result).not.toContain('flatFileMarker');
|
||||
expect(result).toContain('OpenAIV21VideoGenerateConfig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-dir resolution', () => {
|
||||
let builtinDir: string;
|
||||
let communityDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create two separate temp directories to simulate built-in and community dirs
|
||||
builtinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'builtin-nodes-test-'));
|
||||
communityDir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-nodes-test-'));
|
||||
|
||||
// Put nodeA (aggregate) in builtinDir only
|
||||
const builtinNodeDir = path.join(builtinDir, 'nodes/n8n-nodes-base/aggregate');
|
||||
fs.mkdirSync(builtinNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(builtinNodeDir, 'v1.ts'),
|
||||
'export type AggregateV1Config = { source: "builtin" };',
|
||||
);
|
||||
fs.writeFileSync(path.join(builtinNodeDir, 'index.ts'), "export * from './v1';");
|
||||
|
||||
// Put nodeB (customNode) in communityDir only
|
||||
const communityNodeDir = path.join(communityDir, 'nodes/n8n-nodes-community/customNode');
|
||||
fs.mkdirSync(communityNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(communityNodeDir, 'v1.ts'),
|
||||
'export type CustomNodeV1Config = { source: "community" };',
|
||||
);
|
||||
fs.writeFileSync(path.join(communityNodeDir, 'index.ts'), "export * from './v1';");
|
||||
|
||||
// Put aggregate in communityDir too (to test priority)
|
||||
const communityOverrideDir = path.join(communityDir, 'nodes/n8n-nodes-base/aggregate');
|
||||
fs.mkdirSync(communityOverrideDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(communityOverrideDir, 'v1.ts'),
|
||||
'export type AggregateV1Config = { source: "community-override" };',
|
||||
);
|
||||
fs.writeFileSync(path.join(communityOverrideDir, 'index.ts'), "export * from './v1';");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(builtinDir, { recursive: true, force: true });
|
||||
fs.rmSync(communityDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should resolve node from built-in dir when it exists there', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [builtinDir, communityDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.aggregate'],
|
||||
});
|
||||
|
||||
// Should find it in builtinDir (first dir searched)
|
||||
expect(result).toContain('source: "builtin"');
|
||||
expect(result).not.toContain('community-override');
|
||||
});
|
||||
|
||||
it('should resolve node from community dir when not in built-in', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [builtinDir, communityDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-community.customNode'],
|
||||
});
|
||||
|
||||
// Should find it in communityDir (second dir searched)
|
||||
expect(result).toContain('source: "community"');
|
||||
});
|
||||
|
||||
it('should return error when node not found in any dir', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [builtinDir, communityDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.nonExistent'],
|
||||
});
|
||||
|
||||
expect(result).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path traversal security', () => {
|
||||
describe('isValidPathComponent', () => {
|
||||
it('should accept valid alphanumeric components', () => {
|
||||
expect(isValidPathComponent('httpRequest')).toBe(true);
|
||||
expect(isValidPathComponent('n8n-nodes-base')).toBe(true);
|
||||
expect(isValidPathComponent('googleCalendar')).toBe(true);
|
||||
expect(isValidPathComponent('v1')).toBe(true);
|
||||
expect(isValidPathComponent('run_once_for_all_items')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject path traversal sequences', () => {
|
||||
expect(isValidPathComponent('..')).toBe(false);
|
||||
expect(isValidPathComponent('../')).toBe(false);
|
||||
expect(isValidPathComponent('..\\foo')).toBe(false);
|
||||
expect(isValidPathComponent('foo/../bar')).toBe(false);
|
||||
expect(isValidPathComponent('foo/../../etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject absolute paths', () => {
|
||||
expect(isValidPathComponent('/etc/passwd')).toBe(false);
|
||||
expect(isValidPathComponent('/foo')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject path separator injection', () => {
|
||||
expect(isValidPathComponent('foo/bar')).toBe(false);
|
||||
expect(isValidPathComponent('foo\\bar')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty or whitespace-only values', () => {
|
||||
expect(isValidPathComponent('')).toBe(false);
|
||||
expect(isValidPathComponent(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null bytes', () => {
|
||||
expect(isValidPathComponent('foo\0bar')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePathWithinBase', () => {
|
||||
const baseDir = '/tmp/n8n-node-definitions/nodes';
|
||||
|
||||
it('should allow paths within base directory', () => {
|
||||
const validPath = path.join(baseDir, 'n8n-nodes-base', 'httpRequest', 'v1.ts');
|
||||
expect(validatePathWithinBase(validPath, baseDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow nested paths within base directory', () => {
|
||||
const validPath = path.join(
|
||||
baseDir,
|
||||
'n8n-nodes-base',
|
||||
'freshservice',
|
||||
'v1',
|
||||
'resource_ticket',
|
||||
'operation_get.ts',
|
||||
);
|
||||
expect(validatePathWithinBase(validPath, baseDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject paths outside base directory via traversal', () => {
|
||||
const maliciousPath = path.resolve(baseDir, '..', '..', 'etc', 'passwd');
|
||||
expect(validatePathWithinBase(maliciousPath, baseDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject paths that are parents of base directory', () => {
|
||||
const parentPath = path.resolve(baseDir, '..');
|
||||
expect(validatePathWithinBase(parentPath, baseDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle symbolic path tricks', () => {
|
||||
// Even if someone tries baseDir + /../../foo, resolve should catch it
|
||||
const trickyPath = path.join(baseDir, 'package', '..', '..', '..', 'etc', 'passwd');
|
||||
expect(validatePathWithinBase(path.resolve(trickyPath), baseDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool invocation with malicious inputs', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-traversal-test-'));
|
||||
|
||||
// Create a node with resource/operation split structure for testing
|
||||
const splitResourceNodeDir = path.join(
|
||||
tempDir,
|
||||
'nodes/n8n-nodes-base/splitResourceNode/v1',
|
||||
);
|
||||
fs.mkdirSync(splitResourceNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(splitResourceNodeDir, '_shared.ts'),
|
||||
'export interface SharedBase { type: string; }',
|
||||
);
|
||||
// Create a valid resource directory so the structure is recognized
|
||||
const ticketDir = path.join(splitResourceNodeDir, 'resource_ticket');
|
||||
fs.mkdirSync(ticketDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ticketDir, 'operation_get.ts'),
|
||||
'export type Config = { valid: true };',
|
||||
);
|
||||
|
||||
// Create a node with mode split structure for testing
|
||||
const splitModeNodeDir = path.join(tempDir, 'nodes/n8n-nodes-base/splitModeNode/v1');
|
||||
fs.mkdirSync(splitModeNodeDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(splitModeNodeDir, '_shared.ts'),
|
||||
'export interface SharedBase { type: string; }',
|
||||
);
|
||||
// Create a valid mode file so the structure is recognized
|
||||
fs.writeFileSync(
|
||||
path.join(splitModeNodeDir, 'mode_run_once.ts'),
|
||||
'export type Config = { valid: true };',
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should reject nodeId with path traversal', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: ['n8n-nodes-base.../../../etc/passwd'],
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).not.toContain('root:');
|
||||
});
|
||||
|
||||
it('should reject resource discriminator with path traversal', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.splitResourceNode',
|
||||
resource: '../../../etc/passwd',
|
||||
operation: 'get',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).not.toContain('root:');
|
||||
});
|
||||
|
||||
it('should reject mode discriminator with path traversal', async () => {
|
||||
const tool = createCodeBuilderGetTool({ nodeDefinitionDirs: [tempDir] });
|
||||
|
||||
const result = await tool.invoke({
|
||||
nodeIds: [
|
||||
{
|
||||
nodeId: 'n8n-nodes-base.splitModeNode',
|
||||
mode: '../../etc/passwd',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).not.toContain('root:');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+897
@@ -0,0 +1,897 @@
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { NodeTypeParser } from '../../utils/node-type-parser';
|
||||
import { createCodeBuilderSearchTool } from '../code-builder-search.tool';
|
||||
|
||||
// Mock node type with resource/operation pattern (like Freshservice)
|
||||
const mockFreshserviceNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.freshservice',
|
||||
displayName: 'Freshservice',
|
||||
description: 'Consume Freshservice API',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
defaults: { name: 'Freshservice' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [{ name: 'freshserviceApi', required: true }],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Ticket', value: 'ticket' },
|
||||
{ name: 'Agent', value: 'agent' },
|
||||
],
|
||||
default: 'ticket',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['ticket'] } },
|
||||
options: [
|
||||
{ name: 'Get', value: 'get', description: 'Get a ticket' },
|
||||
{ name: 'Create', value: 'create', description: 'Create a ticket' },
|
||||
{ name: 'Delete', value: 'delete', description: 'Delete a ticket' },
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['agent'] } },
|
||||
options: [
|
||||
{ name: 'Get', value: 'get', description: 'Get an agent' },
|
||||
{ name: 'Create', value: 'create', description: 'Create an agent' },
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock node with mode discriminator (like Code node)
|
||||
const mockCodeNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
description: 'Run custom JavaScript code',
|
||||
group: ['transform'],
|
||||
version: 2,
|
||||
defaults: { name: 'Code' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Run Once for All Items', value: 'runOnceForAllItems' },
|
||||
{ name: 'Run Once for Each Item', value: 'runOnceForEachItem' },
|
||||
],
|
||||
default: 'runOnceForAllItems',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock node without discriminators (like HTTP Request)
|
||||
const mockHttpRequestNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
description: 'Makes HTTP requests and returns the response data',
|
||||
group: ['transform'],
|
||||
version: 4,
|
||||
defaults: { name: 'HTTP Request' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Method',
|
||||
name: 'method',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'GET', value: 'GET' },
|
||||
{ name: 'POST', value: 'POST' },
|
||||
],
|
||||
default: 'GET',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock vector store node with mode discriminator that includes outputConnectionType
|
||||
const mockVectorStoreNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.vectorStorePinecone',
|
||||
displayName: 'Pinecone Vector Store',
|
||||
description: 'Work with your data in Pinecone Vector Store',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Pinecone Vector Store' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'load',
|
||||
description: 'Get many ranked documents from vector store for query',
|
||||
},
|
||||
{
|
||||
name: 'Insert Documents',
|
||||
value: 'insert',
|
||||
description: 'Insert documents into vector store',
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
|
||||
value: 'retrieve',
|
||||
description:
|
||||
'Retrieve documents from vector store to be used as vector store with AI nodes',
|
||||
outputConnectionType: NodeConnectionTypes.AiVectorStore,
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Tool for AI Agent)',
|
||||
value: 'retrieve-as-tool',
|
||||
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
|
||||
outputConnectionType: NodeConnectionTypes.AiTool,
|
||||
},
|
||||
{
|
||||
name: 'Update Documents',
|
||||
value: 'update',
|
||||
description: 'Update documents in vector store by ID',
|
||||
},
|
||||
],
|
||||
default: 'load',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock nodes for builder hint tests
|
||||
const mockFormTriggerNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.formTrigger',
|
||||
displayName: 'n8n Form Trigger',
|
||||
description: 'Trigger workflows with an n8n Form submission',
|
||||
group: ['trigger'],
|
||||
version: 2,
|
||||
defaults: { name: 'n8n Form Trigger' },
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message:
|
||||
'Use with n8n-nodes-base.form to build a full form experience, with pages and final page',
|
||||
relatedNodes: [{ nodeType: 'n8n-nodes-base.form', relationHint: 'Build full form experience' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockFormNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.form',
|
||||
displayName: 'n8n Form',
|
||||
description: 'Create a multi-page form for the Form Trigger',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
defaults: { name: 'n8n Form' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message:
|
||||
'Use with n8n-nodes-base.formTrigger to build a full form experience. Form node creates additional pages/steps after the trigger',
|
||||
relatedNodes: [
|
||||
{ nodeType: 'n8n-nodes-base.formTrigger', relationHint: 'Creates additional form pages' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockRespondToWebhookNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.respondToWebhook',
|
||||
displayName: 'Respond to Webhook',
|
||||
description: 'Send custom response to a Webhook or Form Trigger',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Respond to Webhook' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message:
|
||||
'Only works with webhook node (n8n-nodes-base.webhook) with responseMode set to "responseNode"',
|
||||
relatedNodes: [
|
||||
{ nodeType: 'n8n-nodes-base.webhook', relationHint: 'Required webhook trigger' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockWebhookNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.webhook',
|
||||
displayName: 'Webhook',
|
||||
description: 'Starts the workflow on a webhook call',
|
||||
group: ['trigger'],
|
||||
version: 2,
|
||||
defaults: { name: 'Webhook' },
|
||||
inputs: [],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
// Mock AI Agent node with builderHint for structured output
|
||||
const mockAgentNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
description: 'Generates an action plan and executes it. Can use external tools.',
|
||||
group: ['transform'],
|
||||
version: 3.1,
|
||||
defaults: { name: 'AI Agent' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message:
|
||||
'Use with @n8n/n8n-nodes-langchain.outputParserStructured to get structured JSON output from the agent',
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
relationHint: 'Structured JSON output',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock node with NEW relatedNodes format (with relationHint)
|
||||
const mockAgentNodeWithRelationHints: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.agentV2',
|
||||
displayName: 'AI Agent V2',
|
||||
description: 'Generates an action plan and executes it. Can use external tools.',
|
||||
group: ['transform'],
|
||||
version: 3.1,
|
||||
defaults: { name: 'AI Agent V2' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Always connect memory for conversation context',
|
||||
relatedNodes: [
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
relationHint: 'Maintains conversation history for context',
|
||||
},
|
||||
{
|
||||
nodeType: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
relationHint: 'Connect a chat model for responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock memory node for relation hint tests
|
||||
const mockMemoryNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
displayName: 'Simple Memory',
|
||||
description: 'Stores conversation history in memory',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Simple Memory' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
// Mock chat model node for relation hint tests
|
||||
const mockChatModelNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
description: 'Access OpenAI chat models',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'OpenAI Chat Model' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
// Mock Output Parser Structured node
|
||||
const mockOutputParserStructuredNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.outputParserStructured',
|
||||
displayName: 'Structured Output Parser',
|
||||
description: 'Parse the output of a language model into a structured format using a JSON schema',
|
||||
group: ['transform'],
|
||||
version: 1.2,
|
||||
defaults: { name: 'Structured Output Parser' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
// Mock operation-only node (like Remove Duplicates V2 — has operation but no resource)
|
||||
const mockRemoveDuplicatesNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
description: 'Delete items with matching field values',
|
||||
group: ['transform'],
|
||||
version: 2,
|
||||
defaults: { name: 'Remove Duplicates' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Remove Items Repeated Within Current Input',
|
||||
value: 'removeDuplicateInputItems',
|
||||
description: 'Remove duplicates from incoming items',
|
||||
},
|
||||
{
|
||||
name: 'Remove Items Processed in Previous Executions',
|
||||
value: 'removeItemsSeenInPreviousExecutions',
|
||||
description: 'Deduplicate items already seen in previous executions',
|
||||
},
|
||||
{
|
||||
name: 'Clear Deduplication History',
|
||||
value: 'clearDeduplicationHistory',
|
||||
description: 'Wipe the store of previous items',
|
||||
},
|
||||
],
|
||||
default: 'removeDuplicateInputItems',
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: 'cleanDatabase',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['clearDeduplicationHistory'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Clean Database',
|
||||
value: 'cleanDatabase',
|
||||
description: 'Clear all values stored for a key in the database',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock node that references Code node as related (for testing discriminators in related nodes)
|
||||
const mockCodeRunnerNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.codeRunner',
|
||||
displayName: 'Code Runner',
|
||||
description: 'A wrapper that runs code using the Code node',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Code Runner' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Use with n8n-nodes-base.code for executing custom JavaScript',
|
||||
relatedNodes: [{ nodeType: 'n8n-nodes-base.code', relationHint: 'Execute custom JavaScript' }],
|
||||
},
|
||||
};
|
||||
|
||||
describe('CodeBuilderSearchTool', () => {
|
||||
describe('builder hints in search results', () => {
|
||||
it('should include builder hint for Form Trigger node', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockFormTriggerNode, mockFormNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['form trigger'] });
|
||||
|
||||
expect(result).toContain('n8n-nodes-base.formTrigger');
|
||||
expect(result).toContain('@builderHint');
|
||||
expect(result).toContain('n8n-nodes-base.form');
|
||||
expect(result).toContain('full form experience');
|
||||
});
|
||||
|
||||
it('should automatically include related nodes in search results', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockRespondToWebhookNode, mockWebhookNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// Search for "respond" which should only match respondToWebhook, not webhook
|
||||
const result = await tool.invoke({ queries: ['respond'] });
|
||||
|
||||
// Should include Webhook node via @relatedNodes section
|
||||
expect(result).toContain('@relatedNodes');
|
||||
expect(result).toContain('n8n-nodes-base.webhook');
|
||||
});
|
||||
|
||||
it('should include builder hint for Respond to Webhook node', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockRespondToWebhookNode, mockWebhookNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['respond webhook'] });
|
||||
|
||||
expect(result).toContain('n8n-nodes-base.respondToWebhook');
|
||||
expect(result).toContain('@builderHint');
|
||||
expect(result).toContain('responseMode');
|
||||
expect(result).toContain('responseNode');
|
||||
});
|
||||
|
||||
it('should include builder hint for Form node', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockFormNode, mockFormTriggerNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['form'] });
|
||||
|
||||
expect(result).toContain('n8n-nodes-base.form');
|
||||
expect(result).toContain('@builderHint');
|
||||
expect(result).toContain('formTrigger');
|
||||
});
|
||||
|
||||
it('should include builder hint for AI Agent node with outputParserStructured', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode, mockOutputParserStructuredNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['agent'] });
|
||||
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result).toContain('@builderHint');
|
||||
expect(result).toContain('outputParserStructured');
|
||||
expect(result).toContain('structured JSON output');
|
||||
});
|
||||
|
||||
it('should include outputParserStructured as related node when searching for AI Agent only', async () => {
|
||||
// Only include Agent node, so outputParserStructured will be added as [RELATED]
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['AI Agent'] });
|
||||
|
||||
// Agent should be found
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result).toContain('@builderHint');
|
||||
// outputParserStructured is in relatedNodes but not in the nodeTypeParser,
|
||||
// so it won't appear as [RELATED] (can't find node info for it)
|
||||
// This tests that the hint message itself references the related node
|
||||
expect(result).toContain('outputParserStructured');
|
||||
});
|
||||
|
||||
it('should show outputParserStructured in @relatedNodes when both nodes available but only agent matches search', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode, mockOutputParserStructuredNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// Search specifically for "AI Agent" - should only match agent node directly
|
||||
const result = await tool.invoke({ queries: ['AI Agent'] });
|
||||
|
||||
// Agent should be found directly
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result).toContain('@builderHint');
|
||||
|
||||
// outputParserStructured should appear in @relatedNodes section
|
||||
expect(result).toContain('@relatedNodes');
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.outputParserStructured');
|
||||
expect(result).toContain('Structured JSON output');
|
||||
});
|
||||
|
||||
it('should display related nodes inline via @relatedNodes (nodeA → nodeB)', async () => {
|
||||
// Create a chain: nodeA → nodeB → nodeC
|
||||
// When searching for nodeA, nodeB should appear in @relatedNodes section
|
||||
const mockNodeA: INodeTypeDescription = {
|
||||
name: 'test.chainA',
|
||||
displayName: 'Chain Node A',
|
||||
description: 'First node in chain',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Chain Node A' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Related to Chain Node B',
|
||||
relatedNodes: [{ nodeType: 'test.chainB', relationHint: 'Next in chain' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNodeB: INodeTypeDescription = {
|
||||
name: 'test.chainB',
|
||||
displayName: 'Chain Node B',
|
||||
description: 'Second node in chain',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Chain Node B' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Related to Chain Node C',
|
||||
relatedNodes: [{ nodeType: 'test.chainC', relationHint: 'Next in chain' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNodeC: INodeTypeDescription = {
|
||||
name: 'test.chainC',
|
||||
displayName: 'Chain Node C',
|
||||
description: 'Third node in chain',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Chain Node C' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const nodeTypeParser = new NodeTypeParser([mockNodeA, mockNodeB, mockNodeC]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// Search for "Chain Node A" - should only match nodeA directly
|
||||
const result = await tool.invoke({ queries: ['Chain Node A'] });
|
||||
|
||||
// Node A should be found directly
|
||||
expect(result).toContain('test.chainA');
|
||||
|
||||
// Node B should appear in @relatedNodes section (direct relation from A)
|
||||
expect(result).toContain('@relatedNodes');
|
||||
expect(result).toContain('test.chainB');
|
||||
});
|
||||
|
||||
it('should prevent infinite recursion with circular related nodes', async () => {
|
||||
// Create nodes with circular references: A → B → A
|
||||
const mockNodeA: INodeTypeDescription = {
|
||||
name: 'test.nodeA',
|
||||
displayName: 'Node A',
|
||||
description: 'Test node A',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Node A' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Related to Node B',
|
||||
relatedNodes: [{ nodeType: 'test.nodeB', relationHint: 'Related node' }],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNodeB: INodeTypeDescription = {
|
||||
name: 'test.nodeB',
|
||||
displayName: 'Node B',
|
||||
description: 'Test node B',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Node B' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
builderHint: {
|
||||
message: 'Related to Node A',
|
||||
relatedNodes: [{ nodeType: 'test.nodeA', relationHint: 'Related node' }],
|
||||
},
|
||||
};
|
||||
|
||||
const nodeTypeParser = new NodeTypeParser([mockNodeA, mockNodeB]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// This should not hang or throw due to infinite recursion
|
||||
const result = await tool.invoke({ queries: ['Node A'] });
|
||||
|
||||
// Node A should be found
|
||||
expect(result).toContain('test.nodeA');
|
||||
|
||||
// Node B should appear in @relatedNodes section (only once, not infinitely)
|
||||
expect(result).toContain('@relatedNodes');
|
||||
expect(result).toContain('test.nodeB');
|
||||
});
|
||||
|
||||
it('should NOT duplicate related nodes if already in search results', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockFormTriggerNode, mockFormNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// Search for both nodes - form should match both
|
||||
const result = await tool.invoke({ queries: ['form'] });
|
||||
|
||||
// Count occurrences of form node ID
|
||||
const formMatches = result.match(/n8n-nodes-base\.form[^T]/g) ?? [];
|
||||
// May appear as: search result, hint text, @relatedNodes section
|
||||
expect(formMatches.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should NOT include builder hint for nodes without hints', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockHttpRequestNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['http'] });
|
||||
|
||||
expect(result).toContain('n8n-nodes-base.httpRequest');
|
||||
expect(result).not.toContain('@builderHint');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relatedNodes with relationHint', () => {
|
||||
it('should display relatedNodes with their relationHint under @relatedNodes section', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockAgentNodeWithRelationHints,
|
||||
mockMemoryNode,
|
||||
mockChatModelNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['AI Agent V2'] });
|
||||
|
||||
// Should find the agent node
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agentV2');
|
||||
|
||||
// Should have @relatedNodes section
|
||||
expect(result).toContain('@relatedNodes');
|
||||
|
||||
// Should show related nodes with their hints
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.memoryBufferWindow');
|
||||
expect(result).toContain('Maintains conversation history for context');
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi');
|
||||
expect(result).toContain('Connect a chat model for responses');
|
||||
});
|
||||
|
||||
it('should format relatedNodes as nodeType: "relationHint"', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockAgentNodeWithRelationHints,
|
||||
mockMemoryNode,
|
||||
mockChatModelNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['AI Agent V2'] });
|
||||
|
||||
// Should format as: - nodeType: "hint"
|
||||
expect(result).toMatch(
|
||||
/@n8n\/n8n-nodes-langchain\.memoryBufferWindow.*"Maintains conversation history for context"/s,
|
||||
);
|
||||
expect(result).toMatch(
|
||||
/@n8n\/n8n-nodes-langchain\.lmChatOpenAi.*"Connect a chat model for responses"/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT expand relatedNodes as full search results when using new format', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockAgentNodeWithRelationHints,
|
||||
mockMemoryNode,
|
||||
mockChatModelNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['AI Agent V2'] });
|
||||
|
||||
// Should NOT have [RELATED] tags (old behavior)
|
||||
expect(result).not.toContain('[RELATED]');
|
||||
|
||||
// Should NOT show "(+ X related)" count in header
|
||||
expect(result).not.toMatch(/\(\+ \d+ related\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discriminator information in search results', () => {
|
||||
it('should include resource/operation info for nodes with that pattern', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockFreshserviceNode,
|
||||
mockCodeNode,
|
||||
mockHttpRequestNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['freshservice'] });
|
||||
|
||||
// Should include discriminator section
|
||||
expect(result).toContain('Discriminators:');
|
||||
|
||||
// Should show resource info
|
||||
expect(result).toContain('resource:');
|
||||
expect(result).toContain('ticket');
|
||||
expect(result).toContain('agent');
|
||||
|
||||
// Should show operations for each resource
|
||||
expect(result).toContain('operations:');
|
||||
expect(result).toContain('get');
|
||||
expect(result).toContain('create');
|
||||
expect(result).toContain('delete');
|
||||
|
||||
// Should include usage hint for get_node_types
|
||||
expect(result).toContain('get_node_types');
|
||||
expect(result).toContain('resource');
|
||||
expect(result).toContain('operation');
|
||||
});
|
||||
|
||||
it('should include mode discriminator for Code node', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockFreshserviceNode,
|
||||
mockCodeNode,
|
||||
mockHttpRequestNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['code'] });
|
||||
|
||||
// Should include discriminator section
|
||||
expect(result).toContain('Discriminators:');
|
||||
|
||||
// Should show mode info
|
||||
expect(result).toContain('mode:');
|
||||
expect(result).toContain('runOnceForAllItems');
|
||||
expect(result).toContain('runOnceForEachItem');
|
||||
|
||||
// Should include usage hint
|
||||
expect(result).toContain('get_node_types');
|
||||
expect(result).toContain('mode');
|
||||
});
|
||||
|
||||
it('should show "none" discriminators for nodes without patterns', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockFreshserviceNode,
|
||||
mockCodeNode,
|
||||
mockHttpRequestNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['http request'] });
|
||||
|
||||
// HTTP Request node has no discriminators, so it should show "none"
|
||||
expect(result).toContain('n8n-nodes-base.httpRequest');
|
||||
expect(result).toContain('HTTP Request');
|
||||
|
||||
// Should show "Discriminators: none" for HTTP Request
|
||||
expect(result).toContain('Discriminators: none');
|
||||
});
|
||||
|
||||
it('should provide correct usage example in the output', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockFreshserviceNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['freshservice'] });
|
||||
|
||||
// Should show how to call get_node_types with discriminators
|
||||
expect(result).toMatch(/get_node_types.*nodeId.*freshservice.*resource.*operation/s);
|
||||
});
|
||||
|
||||
it('should include operation discriminator for operation-only nodes', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockRemoveDuplicatesNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['remove duplicates'] });
|
||||
|
||||
// Should include discriminator section with operation type
|
||||
expect(result).toContain('Discriminators:');
|
||||
expect(result).toContain('operation:');
|
||||
|
||||
// Should show all 3 operation values with descriptions
|
||||
expect(result).toContain('removeDuplicateInputItems');
|
||||
expect(result).toContain('Remove duplicates from incoming items');
|
||||
expect(result).toContain('removeItemsSeenInPreviousExecutions');
|
||||
expect(result).toContain('Deduplicate items already seen in previous executions');
|
||||
expect(result).toContain('clearDeduplicationHistory');
|
||||
expect(result).toContain('Wipe the store of previous items');
|
||||
|
||||
// Should NOT show the subordinate mode discriminator
|
||||
expect(result).not.toContain('cleanDatabase');
|
||||
|
||||
// Should include usage hint
|
||||
expect(result).toContain('get_node_types');
|
||||
expect(result).toMatch(/operation.*removeDuplicateInputItems/s);
|
||||
});
|
||||
|
||||
it('should include related nodes in @relatedNodes section', async () => {
|
||||
// mockCodeRunnerNode has mockCodeNode as a related node
|
||||
const nodeTypeParser = new NodeTypeParser([mockCodeRunnerNode, mockCodeNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
// Search for "Code Runner" - should find codeRunner directly, Code in @relatedNodes
|
||||
const result = await tool.invoke({ queries: ['Code Runner'] });
|
||||
|
||||
// Code Runner should be found directly
|
||||
expect(result).toContain('n8n-nodes-base.codeRunner');
|
||||
|
||||
// Code node should appear in @relatedNodes section
|
||||
expect(result).toContain('@relatedNodes');
|
||||
expect(result).toContain('n8n-nodes-base.code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCodeBuilderSearchTool', () => {
|
||||
it('should create tool with correct name', () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockHttpRequestNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
expect(tool.name).toBe('search_nodes');
|
||||
});
|
||||
|
||||
it('should return results for multiple queries', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([
|
||||
mockFreshserviceNode,
|
||||
mockCodeNode,
|
||||
mockHttpRequestNode,
|
||||
]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['freshservice', 'code'] });
|
||||
|
||||
// Should contain results for both queries
|
||||
expect(result).toContain('"freshservice"');
|
||||
expect(result).toContain('"code"');
|
||||
expect(result).toContain('Freshservice');
|
||||
expect(result).toContain('Code');
|
||||
});
|
||||
|
||||
it('should handle queries with no results', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockHttpRequestNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['nonexistent-node-xyz'] });
|
||||
|
||||
expect(result).toContain('No nodes found');
|
||||
expect(result).toContain('Try a different search term');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode discriminator with outputConnectionType and SDK function mapping', () => {
|
||||
it('should show display name and SDK function for modes with outputConnectionType', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockVectorStoreNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['pinecone'] });
|
||||
|
||||
// Should include discriminator section
|
||||
expect(result).toContain('Discriminators:');
|
||||
expect(result).toContain('mode:');
|
||||
|
||||
// Should show mode values with display names
|
||||
expect(result).toContain('retrieve');
|
||||
expect(result).toContain('retrieve-as-tool');
|
||||
|
||||
// Should show OVERRIDDEN display name for retrieve (without confusing "/Tool" text)
|
||||
expect(result).toContain('Retrieve Documents (As Vector Store for Chain)');
|
||||
expect(result).not.toContain('Retrieve Documents (As Vector Store for Chain/Tool)');
|
||||
expect(result).toContain('Retrieve Documents (As Tool for AI Agent)');
|
||||
|
||||
// Should show SDK function mapping WITH mode parameter for modes with outputConnectionType
|
||||
// retrieve → vectorStore({ mode: 'retrieve' }) for subnodes.vectorStore
|
||||
expect(result).toMatch(/retrieve.*vectorStore\(\{ mode: 'retrieve' \}\)/s);
|
||||
|
||||
// retrieve-as-tool → tool({ mode: 'retrieve-as-tool' }) for subnodes.tools
|
||||
expect(result).toMatch(/retrieve-as-tool.*tool\(\{ mode: 'retrieve-as-tool' \}\)/s);
|
||||
});
|
||||
|
||||
it('should show node() for modes without outputConnectionType', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockVectorStoreNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['pinecone'] });
|
||||
|
||||
// Modes without outputConnectionType should be shown as standalone
|
||||
expect(result).toContain('load');
|
||||
expect(result).toContain('Get Many');
|
||||
expect(result).toContain('insert');
|
||||
expect(result).toContain('Insert Documents');
|
||||
expect(result).toContain('update');
|
||||
expect(result).toContain('Update Documents');
|
||||
|
||||
// These should indicate they use node() function (no mode parameter needed for node())
|
||||
expect(result).toMatch(/load.*Get Many.*→ use node\(\)/is);
|
||||
});
|
||||
|
||||
it('should NOT show SDK function for nodes where all modes use node()', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockCodeNode]);
|
||||
const tool = createCodeBuilderSearchTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ queries: ['code'] });
|
||||
|
||||
// Should show mode values with display names
|
||||
expect(result).toContain('runOnceForAllItems');
|
||||
expect(result).toContain('Run Once for All Items');
|
||||
expect(result).toContain('runOnceForEachItem');
|
||||
expect(result).toContain('Run Once for Each Item');
|
||||
|
||||
// Code node modes all use node(), so should NOT show SDK function mapping
|
||||
expect(result).not.toContain('node()');
|
||||
expect(result).not.toContain('→ use');
|
||||
});
|
||||
});
|
||||
});
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { NodeTypeParser } from '../../utils/node-type-parser';
|
||||
import { createGetSuggestedNodesTool } from '../get-suggested-nodes.tool';
|
||||
import { categoryList, suggestedNodesData } from '../suggested-nodes-data';
|
||||
|
||||
// Mock node types that match some suggested nodes
|
||||
const mockSlackNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.slack',
|
||||
displayName: 'Slack',
|
||||
description: 'Send messages to Slack channels',
|
||||
group: ['output'],
|
||||
version: 2,
|
||||
defaults: { name: 'Slack' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const mockDataTableNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.dataTable',
|
||||
displayName: 'Data Table',
|
||||
description: 'Store and query data in n8n',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Data Table' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const mockAgentNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
description: 'Generates an action plan and executes it',
|
||||
group: ['transform'],
|
||||
version: 3.1,
|
||||
defaults: { name: 'AI Agent' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const mockMemoryNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
|
||||
displayName: 'Window Buffer Memory',
|
||||
description: 'Stores conversation history in memory',
|
||||
group: ['transform'],
|
||||
version: 1.3,
|
||||
defaults: { name: 'Window Buffer Memory' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
describe('GetSuggestedNodesTool', () => {
|
||||
describe('createGetSuggestedNodesTool', () => {
|
||||
it('should create tool with correct name', () => {
|
||||
const nodeTypeParser = new NodeTypeParser([]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
expect(tool.name).toBe('get_suggested_nodes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single category', () => {
|
||||
it('should return nodes for chatbot category', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode, mockMemoryNode, mockSlackNode]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot'] });
|
||||
|
||||
expect(result).toContain('chatbot');
|
||||
expect(result).toContain('patternHint');
|
||||
expect(result).toContain('Chat Trigger → AI Agent → Memory → Response');
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should return nodes for data_persistence category', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockDataTableNode]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['data_persistence'] });
|
||||
|
||||
expect(result).toContain('data_persistence');
|
||||
expect(result).toContain('n8n-nodes-base.dataTable');
|
||||
expect(result).toContain('PREFERRED - no external config needed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple categories', () => {
|
||||
it('should return nodes for multiple categories', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode, mockDataTableNode, mockSlackNode]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot', 'notification'] });
|
||||
|
||||
expect(result).toContain('chatbot');
|
||||
expect(result).toContain('notification');
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result).toContain('n8n-nodes-base.slack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node descriptions from parser', () => {
|
||||
it('should include node description from nodeTypeParser', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot'] });
|
||||
|
||||
expect(result).toContain('AI Agent');
|
||||
expect(result).toContain('Generates an action plan and executes it');
|
||||
});
|
||||
|
||||
it('should indicate when node is not found in parser', async () => {
|
||||
// Empty parser - no nodes available
|
||||
const nodeTypeParser = new NodeTypeParser([]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot'] });
|
||||
|
||||
// Should still list the node but indicate it's not found
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.chatTrigger');
|
||||
expect(result).toContain('(not found)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notes for nodes', () => {
|
||||
it('should include notes when present', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([mockAgentNode]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot'] });
|
||||
|
||||
// Agent has note about memory subnode requirement
|
||||
expect(result).toContain(
|
||||
'Every agent in a conversational workflow MUST have a memory subnode connected',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include note marker when no note', async () => {
|
||||
// lmChatOpenAi has no note in chatbot category
|
||||
const mockLmChatOpenAi: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
displayName: 'OpenAI Chat Model',
|
||||
description: 'Use OpenAI Chat models',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'OpenAI Chat Model' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
const nodeTypeParser = new NodeTypeParser([mockLmChatOpenAi]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['chatbot'] });
|
||||
|
||||
// lmChatOpenAi should be there
|
||||
expect(result).toContain('@n8n/n8n-nodes-langchain.lmChatOpenAi');
|
||||
// And have description
|
||||
expect(result).toContain('Use OpenAI Chat models');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid categories', () => {
|
||||
it('should handle unknown category gracefully', async () => {
|
||||
const nodeTypeParser = new NodeTypeParser([]);
|
||||
const tool = createGetSuggestedNodesTool(nodeTypeParser);
|
||||
|
||||
const result = await tool.invoke({ categories: ['unknown_category'] });
|
||||
|
||||
expect(result).toContain('unknown_category');
|
||||
expect(result).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('category data validation', () => {
|
||||
it('should have all 11 expected categories', () => {
|
||||
expect(categoryList).toHaveLength(11);
|
||||
expect(categoryList).toContain('chatbot');
|
||||
expect(categoryList).toContain('notification');
|
||||
expect(categoryList).toContain('scheduling');
|
||||
expect(categoryList).toContain('data_transformation');
|
||||
expect(categoryList).toContain('data_persistence');
|
||||
expect(categoryList).toContain('data_extraction');
|
||||
expect(categoryList).toContain('document_processing');
|
||||
expect(categoryList).toContain('form_input');
|
||||
expect(categoryList).toContain('content_generation');
|
||||
expect(categoryList).toContain('triage');
|
||||
expect(categoryList).toContain('scraping_and_research');
|
||||
});
|
||||
|
||||
it('each category should have description, patternHint, and nodes', () => {
|
||||
for (const category of categoryList) {
|
||||
const data = suggestedNodesData[category];
|
||||
expect(data.description).toBeDefined();
|
||||
expect(data.description.length).toBeGreaterThan(0);
|
||||
expect(data.patternHint).toBeDefined();
|
||||
expect(data.patternHint.length).toBeGreaterThan(0);
|
||||
expect(data.nodes).toBeDefined();
|
||||
expect(data.nodes.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createThinkTool } from '../think.tool';
|
||||
|
||||
describe('createThinkTool', () => {
|
||||
it('should create a tool named "think"', () => {
|
||||
const tool = createThinkTool();
|
||||
expect(tool.name).toBe('think');
|
||||
});
|
||||
|
||||
it('should return acknowledgement when invoked', async () => {
|
||||
const tool = createThinkTool();
|
||||
const result = await tool.invoke({
|
||||
thought: 'I need to use the Gmail node for sending emails',
|
||||
});
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should accept any string as thought', async () => {
|
||||
const tool = createThinkTool();
|
||||
const result = await tool.invoke({ thought: '' });
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Think Tool
|
||||
*
|
||||
* A no-op tool that gives the LLM a structured place to reason between tool calls.
|
||||
* When extended thinking is enabled, the model's native thinking block handles
|
||||
* pre-response reasoning. But mid-response reasoning (between tool calls) needs
|
||||
* a dedicated tool to prevent thinking from leaking into visible output tokens.
|
||||
*
|
||||
* See: https://www.anthropic.com/engineering/claude-think-tool
|
||||
*/
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Create a think tool for structured mid-response reasoning.
|
||||
*
|
||||
* The tool is a no-op — it simply acknowledges the thought. Its purpose is to
|
||||
* give the model a structured place to reason about tool results and plan next
|
||||
* steps without that reasoning appearing in the visible output.
|
||||
*/
|
||||
export function createThinkTool() {
|
||||
return tool(
|
||||
async (_input: { thought: string }) => {
|
||||
return 'Thought recorded.';
|
||||
},
|
||||
{
|
||||
name: 'think',
|
||||
description:
|
||||
'Use this tool to reason about tool results, plan your next step, or analyze information. Your thought will not be shown to the user.',
|
||||
schema: z.object({
|
||||
thought: z.string().describe('Your internal reasoning or analysis'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Types for the Code Builder Agent
|
||||
*
|
||||
* Extracted from code-builder-agent.ts for better organization and testability.
|
||||
*/
|
||||
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import type {
|
||||
IDisplayOptions,
|
||||
INodeTypeDescription,
|
||||
ITelemetryTrackProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
// ============================================================================
|
||||
// Forked types from types/nodes.ts for code-builder specific features
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents a subnode requirement for AI nodes
|
||||
* Extracted from builderHint.inputs on node type descriptions
|
||||
*/
|
||||
export interface SubnodeRequirement {
|
||||
/** The connection type (e.g., 'ai_languageModel', 'ai_memory') */
|
||||
connectionType: string;
|
||||
/** Whether this subnode is required */
|
||||
required: boolean;
|
||||
/** Conditions under which this subnode is required (e.g., when hasOutputParser is true) */
|
||||
displayOptions?: IDisplayOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result with scoring and subnode requirements
|
||||
*
|
||||
* This is an extended version of the base NodeSearchResult that includes
|
||||
* builder hints and subnode requirements for the code-builder agent.
|
||||
*/
|
||||
export interface CodeBuilderNodeSearchResult {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number;
|
||||
score: number;
|
||||
inputs: INodeTypeDescription['inputs'];
|
||||
outputs: INodeTypeDescription['outputs'];
|
||||
/** General hint message for workflow builders (from builderHint.message) */
|
||||
builderHintMessage?: string;
|
||||
/** Subnode requirements extracted from builderHint.inputs */
|
||||
subnodeRequirements?: SubnodeRequirement[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Code Builder Agent Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Structured output type for the LLM response
|
||||
*/
|
||||
export interface WorkflowCodeOutput {
|
||||
workflowCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation warning with optional location info
|
||||
*/
|
||||
export interface ValidationWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
nodeName?: string;
|
||||
parameterPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from parseAndValidate including workflow and any warnings
|
||||
*/
|
||||
export interface ParseAndValidateResult {
|
||||
workflow: WorkflowJSON;
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage data reported by callback
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thinkingTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the code builder agent
|
||||
*/
|
||||
export interface CodeBuilderAgentConfig {
|
||||
/** LLM for generation */
|
||||
llm: BaseChatModel;
|
||||
/** Parsed node types from n8n */
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
/** Optional logger */
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
/**
|
||||
* Enable the text editor tool for targeted code edits.
|
||||
* If not specified, auto-enabled for Claude 4.x models.
|
||||
*/
|
||||
enableTextEditor?: boolean;
|
||||
/**
|
||||
* Optional callback to receive token usage data from each LLM invocation.
|
||||
* Called after each LLM call completes. Callers can accumulate for totals.
|
||||
*/
|
||||
onTokenUsage?: (usage: TokenUsage) => void;
|
||||
/**
|
||||
* Optional LangChain callbacks (e.g., LangSmith tracer) for LLM invocations.
|
||||
*/
|
||||
callbacks?: Callbacks;
|
||||
/**
|
||||
* Optional metadata to include in LangSmith traces.
|
||||
*/
|
||||
runMetadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Optional callback for emitting telemetry events.
|
||||
*/
|
||||
onTelemetryEvent?: (event: string, properties: ITelemetryTrackProperties) => void;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Code Builder Session Management
|
||||
*
|
||||
* Provides session persistence for the CodeBuilderAgent, enabling multi-turn
|
||||
* conversations where users can make incremental refinement requests like
|
||||
* "change the trigger to Slack" without re-explaining the whole workflow.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Stores user messages only (no AI responses or generated code)
|
||||
* - Uses MemorySaver for in-memory persistence
|
||||
* - Compacts oldest messages when count exceeds threshold
|
||||
*/
|
||||
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
import type { MemorySaver, Checkpoint } from '@langchain/langgraph';
|
||||
|
||||
import { conversationCompactChain } from '../../chains/conversation-compact';
|
||||
import { generateThreadId } from '../../utils/thread-id';
|
||||
|
||||
/** Maximum number of user messages to retain before compaction */
|
||||
const MAX_USER_MESSAGES = 20;
|
||||
|
||||
/** Number of oldest messages to compact when threshold is exceeded */
|
||||
const MESSAGES_TO_COMPACT = 10;
|
||||
|
||||
/**
|
||||
* Session state for the CodeBuilder
|
||||
*/
|
||||
export interface CodeBuilderSession {
|
||||
/** Array of raw user messages (most recent conversation history) */
|
||||
userMessages: string[];
|
||||
/** Compacted summary of older conversations (if any compaction has occurred) */
|
||||
previousSummary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal checkpoint structure for session storage
|
||||
*/
|
||||
interface SessionCheckpoint {
|
||||
userMessages: string[];
|
||||
previousSummary?: string;
|
||||
}
|
||||
|
||||
function isSessionCheckpoint(value: unknown): value is SessionCheckpoint {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'userMessages' in value &&
|
||||
Array.isArray((value as SessionCheckpoint).userMessages)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a CodeBuilder session from the checkpointer
|
||||
*
|
||||
* @param checkpointer - MemorySaver instance for persistence
|
||||
* @param threadId - Unique thread identifier
|
||||
* @returns Session data with userMessages and optional previousSummary
|
||||
*/
|
||||
export async function loadCodeBuilderSession(
|
||||
checkpointer: MemorySaver,
|
||||
threadId: string,
|
||||
): Promise<CodeBuilderSession> {
|
||||
const config: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const checkpointTuple = await checkpointer.getTuple(config);
|
||||
|
||||
if (!checkpointTuple?.checkpoint) {
|
||||
return { userMessages: [] };
|
||||
}
|
||||
|
||||
const channelValues = checkpointTuple.checkpoint.channel_values;
|
||||
const sessionData = channelValues?.codeBuilderSession;
|
||||
|
||||
if (isSessionCheckpoint(sessionData)) {
|
||||
return {
|
||||
userMessages: sessionData.userMessages,
|
||||
previousSummary: sessionData.previousSummary,
|
||||
};
|
||||
}
|
||||
|
||||
return { userMessages: [] };
|
||||
} catch {
|
||||
// Thread doesn't exist yet or error reading
|
||||
return { userMessages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a CodeBuilder session to the checkpointer
|
||||
*
|
||||
* @param checkpointer - MemorySaver instance for persistence
|
||||
* @param threadId - Unique thread identifier
|
||||
* @param session - Session data to persist
|
||||
*/
|
||||
export async function saveCodeBuilderSession(
|
||||
checkpointer: MemorySaver,
|
||||
threadId: string,
|
||||
session: CodeBuilderSession,
|
||||
): Promise<void> {
|
||||
const config: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
},
|
||||
};
|
||||
|
||||
// Get existing checkpoint tuple or create a new base
|
||||
const existingTuple = await checkpointer.getTuple(config).catch(() => undefined);
|
||||
const existingCheckpoint = existingTuple?.checkpoint;
|
||||
|
||||
// Create checkpoint with session data, preserving existing checkpoint properties
|
||||
const checkpoint: Checkpoint = existingCheckpoint
|
||||
? {
|
||||
...existingCheckpoint,
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
...existingCheckpoint.channel_values,
|
||||
codeBuilderSession: {
|
||||
userMessages: session.userMessages,
|
||||
previousSummary: session.previousSummary,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
v: 1,
|
||||
id: crypto.randomUUID(),
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
codeBuilderSession: {
|
||||
userMessages: session.userMessages,
|
||||
previousSummary: session.previousSummary,
|
||||
},
|
||||
},
|
||||
channel_versions: {},
|
||||
versions_seen: {},
|
||||
};
|
||||
|
||||
const metadata = existingTuple?.metadata ?? {
|
||||
source: 'update' as const,
|
||||
step: -1,
|
||||
parents: {},
|
||||
};
|
||||
|
||||
await checkpointer.put(config, checkpoint, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session if the number of user messages exceeds the threshold
|
||||
*
|
||||
* When userMessages.length > MAX_USER_MESSAGES:
|
||||
* 1. Takes the oldest MESSAGES_TO_COMPACT messages
|
||||
* 2. Summarizes them using conversationCompactChain
|
||||
* 3. Appends the summary to previousSummary
|
||||
* 4. Keeps only the most recent messages
|
||||
*
|
||||
* @param session - Current session state
|
||||
* @param llm - Language model for generating summaries
|
||||
* @param maxMessages - Maximum messages before compaction (default: 20)
|
||||
* @returns Updated session (may be unchanged if no compaction needed)
|
||||
*/
|
||||
export async function compactSessionIfNeeded(
|
||||
session: CodeBuilderSession,
|
||||
llm: BaseChatModel,
|
||||
maxMessages: number = MAX_USER_MESSAGES,
|
||||
): Promise<CodeBuilderSession> {
|
||||
if (session.userMessages.length <= maxMessages) {
|
||||
return session;
|
||||
}
|
||||
|
||||
// Get the oldest messages to compact
|
||||
const oldMessages = session.userMessages.slice(0, MESSAGES_TO_COMPACT);
|
||||
const recentMessages = session.userMessages.slice(MESSAGES_TO_COMPACT);
|
||||
|
||||
// Convert to HumanMessage format for the compact chain
|
||||
const messages = oldMessages.map((msg) => new HumanMessage(msg));
|
||||
|
||||
// Run compaction with existing summary
|
||||
const result = await conversationCompactChain(llm, messages, session.previousSummary ?? '');
|
||||
|
||||
// Combine existing summary with new compaction
|
||||
let newSummary: string;
|
||||
if (session.previousSummary) {
|
||||
newSummary = `${session.previousSummary}\n\n---\n\n${result.summaryPlain}`;
|
||||
} else {
|
||||
newSummary = result.summaryPlain;
|
||||
}
|
||||
|
||||
return {
|
||||
userMessages: recentMessages,
|
||||
previousSummary: newSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a thread ID for CodeBuilder sessions
|
||||
*
|
||||
* Uses a consistent format combining workflow ID and user ID to ensure
|
||||
* one session per workflow per user.
|
||||
*
|
||||
* @param workflowId - The workflow ID (from payload.id)
|
||||
* @param userId - The user ID
|
||||
* @returns A unique thread identifier
|
||||
*/
|
||||
export function generateCodeBuilderThreadId(workflowId: string, userId: string): string {
|
||||
return `code-builder-${workflowId}-${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save raw LangChain messages to the SessionManager thread format
|
||||
*
|
||||
* This enables the frontend to retrieve conversation history via getSessions(),
|
||||
* which reads from the `workflow-{workflowId}-user-{userId}` thread format.
|
||||
*
|
||||
* The messages array should contain the full conversation history including
|
||||
* HumanMessage, AIMessage (with tool_calls), and ToolMessage objects.
|
||||
*
|
||||
* @param checkpointer - MemorySaver instance for persistence
|
||||
* @param workflowId - The workflow ID
|
||||
* @param userId - The user ID
|
||||
* @param messages - Raw LangChain messages from the agent
|
||||
* @param versionId - Optional version ID for revert functionality
|
||||
*/
|
||||
export async function saveSessionMessages(
|
||||
checkpointer: MemorySaver,
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
messages: unknown[],
|
||||
versionId?: string,
|
||||
userMessageId?: string,
|
||||
): Promise<void> {
|
||||
const threadId = generateThreadId(workflowId, userId, 'code-builder');
|
||||
|
||||
const config: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
},
|
||||
};
|
||||
|
||||
// Get existing checkpoint or create new base
|
||||
const existingTuple = await checkpointer.getTuple(config).catch(() => undefined);
|
||||
const existingCheckpoint = existingTuple?.checkpoint;
|
||||
|
||||
// Get existing messages array or start fresh
|
||||
const existingMessages: unknown[] =
|
||||
(existingCheckpoint?.channel_values?.messages as unknown[]) ?? [];
|
||||
|
||||
// Use frontend's messageId if provided, otherwise generate a new one (for truncation support)
|
||||
const messageId = userMessageId ?? crypto.randomUUID();
|
||||
|
||||
// Find the index of the first HumanMessage in the batch
|
||||
// (may not be at index 0 if SystemMessage comes first, e.g., in code-builder)
|
||||
const firstHumanMessageIndex = messages.findIndex((msg) => msg instanceof HumanMessage);
|
||||
|
||||
// Add metadata to the first HumanMessage for truncation/restore support
|
||||
const messagesWithMetadata = messages.map((msg, index) => {
|
||||
if (index === firstHumanMessageIndex && msg instanceof HumanMessage) {
|
||||
return new HumanMessage({
|
||||
content: msg.content,
|
||||
additional_kwargs: {
|
||||
...msg.additional_kwargs,
|
||||
messageId,
|
||||
...(versionId && { versionId }),
|
||||
},
|
||||
});
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
// Combine existing messages with new messages
|
||||
const allMessages = [...existingMessages, ...messagesWithMetadata];
|
||||
|
||||
// Create checkpoint with updated messages
|
||||
const checkpoint: Checkpoint = existingCheckpoint
|
||||
? {
|
||||
...existingCheckpoint,
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
...existingCheckpoint.channel_values,
|
||||
messages: allMessages,
|
||||
},
|
||||
}
|
||||
: {
|
||||
v: 1,
|
||||
id: crypto.randomUUID(),
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
messages: allMessages,
|
||||
},
|
||||
channel_versions: {},
|
||||
versions_seen: {},
|
||||
};
|
||||
|
||||
const metadata = existingTuple?.metadata ?? {
|
||||
source: 'update' as const,
|
||||
step: -1,
|
||||
parents: {},
|
||||
};
|
||||
|
||||
await checkpointer.put(config, checkpoint, metadata);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Content Extractor Utilities
|
||||
*
|
||||
* Utilities for extracting text and thinking content from AI messages.
|
||||
*/
|
||||
|
||||
import type { BaseMessage, MessageContent } from '@langchain/core/messages';
|
||||
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
|
||||
/**
|
||||
* Content block type for text content
|
||||
*/
|
||||
interface TextContentBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block type for thinking content (Claude's native thinking)
|
||||
*/
|
||||
interface ThinkingContentBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for text content blocks
|
||||
*/
|
||||
function isTextContentBlock(block: unknown): block is TextContentBlock {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
block.type === 'text' &&
|
||||
'text' in block
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for thinking content blocks
|
||||
*/
|
||||
function isThinkingContentBlock(block: unknown): block is ThinkingContentBlock {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
block.type === 'thinking' &&
|
||||
'thinking' in block
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from an AI message
|
||||
*
|
||||
* Handles both string content and array of content blocks.
|
||||
*
|
||||
* @param message - The AI message to extract text from
|
||||
* @returns The extracted text content, or null if no text found
|
||||
*/
|
||||
export function extractTextContent(message: AIMessage): string | null {
|
||||
// Content can be a string or an array of content blocks
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const textParts = message.content.filter(isTextContentBlock).map((block) => block.text);
|
||||
|
||||
return textParts.length > 0 ? textParts.join('\n') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thinking/planning content from an AI message
|
||||
*
|
||||
* Looks for <thinking> tags and extended thinking blocks.
|
||||
*
|
||||
* @param message - The AI message to extract thinking from
|
||||
* @returns The extracted thinking content, or null if none found
|
||||
*/
|
||||
export function extractThinkingContent(message: AIMessage): string | null {
|
||||
const textContent = extractTextContent(message);
|
||||
if (!textContent) {
|
||||
// Check for extended thinking in content blocks (Claude's native thinking)
|
||||
if (Array.isArray(message.content)) {
|
||||
const thinkingBlocks = message.content
|
||||
.filter(isThinkingContentBlock)
|
||||
.map((block) => block.thinking);
|
||||
|
||||
if (thinkingBlocks.length > 0) {
|
||||
return thinkingBlocks.join('\n\n');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract <thinking> blocks from text content
|
||||
const thinkingMatches = textContent.match(/<thinking>([\s\S]*?)<\/thinking>/g);
|
||||
if (thinkingMatches) {
|
||||
return thinkingMatches.map((match) => match.replace(/<\/?thinking>/g, '').trim()).join('\n\n');
|
||||
}
|
||||
|
||||
// Check for extended thinking in content blocks (Claude's native thinking)
|
||||
if (Array.isArray(message.content)) {
|
||||
const thinkingBlocks = message.content
|
||||
.filter(isThinkingContentBlock)
|
||||
.map((block) => block.thinking);
|
||||
|
||||
if (thinkingBlocks.length > 0) {
|
||||
return thinkingBlocks.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push validation feedback as a synthetic tool call result.
|
||||
*
|
||||
* Injects a validate_workflow tool call into the last AIMessage and appends
|
||||
* a ToolMessage with the result, avoiding consecutive AIMessages.
|
||||
*
|
||||
* When content is an array (e.g. extended thinking), also injects a tool_use
|
||||
* block directly into the content array. LangChain's Anthropic adapter ignores
|
||||
* the tool_calls property when content is an array but correctly serializes
|
||||
* tool_use content blocks.
|
||||
*/
|
||||
export function pushValidationFeedback(messages: BaseMessage[], content: string): void {
|
||||
const toolCallId = `auto-validate-${Date.now()}`;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (lastMessage instanceof AIMessage) {
|
||||
// Build content array with existing content + tool_use block.
|
||||
// Creating a NEW AIMessage (instead of mutating in-place) ensures
|
||||
// LangChain's serialization layer sees the correct internal state.
|
||||
type ContentBlockArray = Exclude<MessageContent, string>;
|
||||
const existingBlocks: ContentBlockArray =
|
||||
typeof lastMessage.content === 'string'
|
||||
? lastMessage.content
|
||||
? [{ type: 'text' as const, text: lastMessage.content }]
|
||||
: []
|
||||
: Array.isArray(lastMessage.content)
|
||||
? [...lastMessage.content]
|
||||
: [];
|
||||
|
||||
const newAiMessage = new AIMessage({
|
||||
content: [
|
||||
...existingBlocks,
|
||||
{ type: 'tool_use', id: toolCallId, name: 'validate_workflow', input: {} },
|
||||
] as ContentBlockArray,
|
||||
tool_calls: [
|
||||
...(lastMessage.tool_calls ?? []),
|
||||
{ id: toolCallId, name: 'validate_workflow', args: {} },
|
||||
],
|
||||
response_metadata: lastMessage.response_metadata,
|
||||
id: lastMessage.id,
|
||||
});
|
||||
|
||||
messages[messages.length - 1] = newAiMessage;
|
||||
}
|
||||
|
||||
messages.push(
|
||||
new ToolMessage({
|
||||
tool_call_id: toolCallId,
|
||||
content,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Utility functions for extracting discriminator information from node types
|
||||
* Used by the search tool to show available discriminators for split type files
|
||||
*/
|
||||
|
||||
import type {
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
IParameterBuilderHint,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Represents a single mode option with its metadata
|
||||
*/
|
||||
export interface ModeInfo {
|
||||
/** The mode value (e.g., 'retrieve', 'retrieve-as-tool') */
|
||||
value: string;
|
||||
/** The display name (e.g., 'Retrieve Documents (As Tool for AI Agent)') */
|
||||
displayName: string;
|
||||
/** The output connection type if this mode creates a subnode (e.g., 'ai_tool', 'ai_vectorStore') */
|
||||
outputConnectionType?: NodeConnectionType;
|
||||
/** The description of what this mode does */
|
||||
description?: string;
|
||||
/** Hint for the AI workflow builder on when to use this mode */
|
||||
builderHint?: IParameterBuilderHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents mode discriminator information
|
||||
*/
|
||||
export interface ModeDiscriminatorInfo {
|
||||
modes: ModeInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract mode discriminator information from a node type
|
||||
* The mode discriminator is used by nodes like Code that have different execution modes
|
||||
*
|
||||
* @param nodeType The node type description
|
||||
* @param nodeVersion The version to extract for
|
||||
* @returns Mode info or null if no mode discriminator found
|
||||
*/
|
||||
export function extractModeDiscriminator(
|
||||
nodeType: INodeTypeDescription,
|
||||
nodeVersion: number,
|
||||
): ModeDiscriminatorInfo | null {
|
||||
const properties = nodeType.properties;
|
||||
if (!properties || properties.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the mode property (skip modes subordinate to operation or resource)
|
||||
const modeProperty = properties.find(
|
||||
(prop) =>
|
||||
prop.name === 'mode' &&
|
||||
prop.type === 'options' &&
|
||||
isPropertyVisibleForVersion(prop, nodeVersion) &&
|
||||
!prop.displayOptions?.show?.operation &&
|
||||
!prop.displayOptions?.show?.resource,
|
||||
);
|
||||
|
||||
if (!modeProperty?.options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract mode values with their metadata
|
||||
const modes: ModeInfo[] = modeProperty.options
|
||||
.filter(
|
||||
(
|
||||
opt,
|
||||
): opt is {
|
||||
name: string;
|
||||
value: string;
|
||||
outputConnectionType?: NodeConnectionType;
|
||||
description?: string;
|
||||
builderHint?: IParameterBuilderHint;
|
||||
} =>
|
||||
typeof opt === 'object' && opt !== null && 'value' in opt && typeof opt.value === 'string',
|
||||
)
|
||||
.map((opt) => ({
|
||||
value: opt.value,
|
||||
displayName: opt.name,
|
||||
outputConnectionType: opt.outputConnectionType,
|
||||
description: opt.description,
|
||||
builderHint: opt.builderHint,
|
||||
}));
|
||||
|
||||
if (modes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { modes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single operation option for operation-only nodes
|
||||
*/
|
||||
export interface OperationOnlyInfo {
|
||||
value: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
builderHint?: IParameterBuilderHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents operation-only discriminator information
|
||||
*/
|
||||
export interface OperationOnlyDiscriminatorInfo {
|
||||
operations: OperationOnlyInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract operation discriminator from nodes that have operation but no resource property.
|
||||
* These nodes (e.g. Remove Duplicates) use operation as their top-level discriminator.
|
||||
*/
|
||||
export function extractOperationOnlyDiscriminator(
|
||||
nodeType: INodeTypeDescription,
|
||||
nodeVersion: number,
|
||||
): OperationOnlyDiscriminatorInfo | null {
|
||||
const properties = nodeType.properties;
|
||||
if (!properties || properties.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Must NOT have a resource property (otherwise extractResourceOperations handles it)
|
||||
const hasResource = properties.some(
|
||||
(prop) =>
|
||||
prop.name === 'resource' &&
|
||||
prop.type === 'options' &&
|
||||
isPropertyVisibleForVersion(prop, nodeVersion),
|
||||
);
|
||||
if (hasResource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find a standalone operation property
|
||||
const operationProperty = properties.find(
|
||||
(prop) =>
|
||||
prop.name === 'operation' &&
|
||||
prop.type === 'options' &&
|
||||
isPropertyVisibleForVersion(prop, nodeVersion),
|
||||
);
|
||||
|
||||
if (!operationProperty?.options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const operations: OperationOnlyInfo[] = operationProperty.options
|
||||
.filter(
|
||||
(
|
||||
opt,
|
||||
): opt is {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
builderHint?: IParameterBuilderHint;
|
||||
} =>
|
||||
typeof opt === 'object' && opt !== null && 'value' in opt && typeof opt.value === 'string',
|
||||
)
|
||||
.map((opt) => ({
|
||||
value: opt.value,
|
||||
displayName: opt.name,
|
||||
description: opt.description,
|
||||
builderHint: opt.builderHint,
|
||||
}));
|
||||
|
||||
if (operations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { operations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a property is visible for a specific node version
|
||||
*/
|
||||
function isPropertyVisibleForVersion(property: INodeProperties, nodeVersion: number): boolean {
|
||||
const displayOptions = property.displayOptions;
|
||||
|
||||
if (!displayOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check @version in 'show' conditions
|
||||
const showVersion = displayOptions.show?.['@version'];
|
||||
if (showVersion) {
|
||||
if (Array.isArray(showVersion)) {
|
||||
if (!showVersion.includes(nodeVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check @version in 'hide' conditions
|
||||
const hideVersion = displayOptions.hide?.['@version'];
|
||||
if (hideVersion) {
|
||||
if (Array.isArray(hideVersion)) {
|
||||
if (hideVersion.includes(nodeVersion)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Comprehensive import statement with all available SDK functions.
|
||||
* This is prepended to workflow code so the LLM knows what's available.
|
||||
*/
|
||||
export const SDK_IMPORT_STATEMENT =
|
||||
"import { workflow, node, trigger, sticky, placeholder, newCredential, ifElse, switchCase, merge, splitInBatches, nextBatch, languageModel, memory, tool, outputParser, embedding, embeddings, vectorStore, retriever, documentLoader, textSplitter, reranker, fromAi, expr } from '@n8n/workflow-sdk';";
|
||||
|
||||
/**
|
||||
* Strip import statements from workflow code.
|
||||
* The SDK functions are available as globals, so imports are not needed at runtime.
|
||||
*/
|
||||
export function stripImportStatements(code: string): string {
|
||||
// Match import statements - handles:
|
||||
// - Single line: import { x } from 'y';
|
||||
// - Multi-line: import {\n x,\n y\n} from 'z';
|
||||
// - Side-effect: import 'module';
|
||||
// - Default: import x from 'y';
|
||||
// - Namespace: import * as x from 'y';
|
||||
const importRegex = /^\s*import\s+(?:[\s\S]*?from\s+)?['"][^'"]+['"];?\s*$/gm;
|
||||
|
||||
return code
|
||||
.replace(importRegex, '')
|
||||
.replace(/^\s*\n/, '') // Remove leading blank line if present
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract workflow code from an LLM response.
|
||||
*
|
||||
* Looks for TypeScript/JavaScript code blocks (```typescript, ```ts, or ```)
|
||||
* and extracts the content. If no code block is found, returns the trimmed response.
|
||||
* Also strips any import statements since SDK functions are available as globals.
|
||||
*
|
||||
* @param response - The LLM response that may contain code in a code block
|
||||
* @returns The extracted code, trimmed
|
||||
*/
|
||||
export function extractWorkflowCode(response: string): string {
|
||||
// Match ```typescript, ```ts, ```javascript, ```js, or ``` code blocks
|
||||
// Use non-greedy match to get first code block
|
||||
const codeBlockRegex = /```(?:typescript|ts|javascript|js)?\n([\s\S]*?)```/;
|
||||
const match = response.match(codeBlockRegex);
|
||||
|
||||
if (match) {
|
||||
const code = match[1].trim();
|
||||
return stripImportStatements(code);
|
||||
}
|
||||
|
||||
// Fallback: return trimmed response if no code block found
|
||||
return stripImportStatements(response.trim());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Format validation warnings for display to the agent.
|
||||
*
|
||||
* When a `WarningTracker` is provided, warnings that existed before the agent
|
||||
* started editing are tagged with `[pre-existing]` so the agent can make an
|
||||
* informed decision about whether to fix them.
|
||||
*/
|
||||
|
||||
import type { WarningTracker } from '../state/warning-tracker';
|
||||
import type { ValidationWarning } from '../types';
|
||||
|
||||
/**
|
||||
* Format an array of validation warnings into a bullet-list string.
|
||||
*
|
||||
* Each warning is formatted as: `- [CODE] message`
|
||||
* If a `warningTracker` is supplied and the warning is pre-existing, the
|
||||
* format becomes: `- [CODE] [pre-existing] message`
|
||||
*
|
||||
* @param warnings - The warnings to format
|
||||
* @param warningTracker - Optional tracker to annotate pre-existing warnings
|
||||
* @returns Newline-separated formatted string
|
||||
*/
|
||||
export function formatWarnings(
|
||||
warnings: ValidationWarning[],
|
||||
warningTracker?: WarningTracker,
|
||||
): string {
|
||||
return warnings
|
||||
.map((w) => {
|
||||
const tag = warningTracker?.isPreExisting(w) ? ' [pre-existing]' : '';
|
||||
return `- [${w.code}]${tag} ${w.message}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* LLM Response Processor
|
||||
*
|
||||
* Extracts and processes data from LLM responses including:
|
||||
* - Token usage from metadata
|
||||
* - Text content
|
||||
* - Thinking/planning content
|
||||
* - Tool calls
|
||||
*/
|
||||
|
||||
import type { AIMessage } from '@langchain/core/messages';
|
||||
|
||||
import { extractTextContent, extractThinkingContent } from './content-extractors';
|
||||
|
||||
/** Type guard for response metadata with usage info */
|
||||
interface ResponseMetadataWithUsage {
|
||||
usage: { input_tokens?: number; output_tokens?: number };
|
||||
}
|
||||
|
||||
function hasUsageMetadata(metadata: unknown): metadata is ResponseMetadataWithUsage {
|
||||
return (
|
||||
typeof metadata === 'object' &&
|
||||
metadata !== null &&
|
||||
'usage' in metadata &&
|
||||
typeof (metadata as ResponseMetadataWithUsage).usage === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call data extracted from LLM response
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processed LLM response data
|
||||
*/
|
||||
export interface LlmResponseResult {
|
||||
/** Number of input tokens used */
|
||||
inputTokens: number;
|
||||
/** Number of output tokens used */
|
||||
outputTokens: number;
|
||||
/** Extracted thinking/planning content, if present */
|
||||
thinkingContent: string | null;
|
||||
/** Extracted text content, if present */
|
||||
textContent: string | null;
|
||||
/** Whether the response contains tool calls */
|
||||
hasToolCalls: boolean;
|
||||
/** Array of tool calls from the response */
|
||||
toolCalls: ToolCall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an LLM response and extract relevant data.
|
||||
*
|
||||
* Consolidates the response processing logic that was previously inline
|
||||
* in the chat() method (lines 403-465).
|
||||
*
|
||||
* @param response - The AIMessage response from the LLM
|
||||
* @returns Processed response data
|
||||
*/
|
||||
export function processLlmResponse(response: AIMessage): LlmResponseResult {
|
||||
// Extract token usage from response metadata using type guard
|
||||
const responseMetadata = response.response_metadata;
|
||||
const inputTokens = hasUsageMetadata(responseMetadata)
|
||||
? (responseMetadata.usage.input_tokens ?? 0)
|
||||
: 0;
|
||||
const outputTokens = hasUsageMetadata(responseMetadata)
|
||||
? (responseMetadata.usage.output_tokens ?? 0)
|
||||
: 0;
|
||||
|
||||
// Extract thinking content
|
||||
const thinkingContent = extractThinkingContent(response);
|
||||
|
||||
// Extract text content
|
||||
const textContent = extractTextContent(response);
|
||||
|
||||
// Extract tool calls
|
||||
const rawToolCalls = response.tool_calls ?? [];
|
||||
const toolCalls: ToolCall[] = rawToolCalls
|
||||
.filter((tc): tc is typeof tc & { id: string } => tc.id !== undefined)
|
||||
.map((tc) => ({
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
}));
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
thinkingContent,
|
||||
textContent,
|
||||
hasToolCalls: toolCalls.length > 0,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Node Diff Utility
|
||||
*
|
||||
* Calculates the difference between two workflow states in terms of nodes:
|
||||
* - nodes_added: nodes present in after but not in before
|
||||
* - nodes_removed: nodes present in before but not in after
|
||||
* - nodes_modified: nodes present in both but with different JSON
|
||||
*/
|
||||
|
||||
interface NodeLike {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface WorkflowLike {
|
||||
nodes: NodeLike[];
|
||||
}
|
||||
|
||||
export interface NodeDiffResult {
|
||||
nodes_added: number;
|
||||
nodes_removed: number;
|
||||
nodes_modified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference between two workflow states in terms of node changes.
|
||||
*
|
||||
* @param before - The workflow state before the change (or null if new)
|
||||
* @param after - The workflow state after the change (or null if deleted)
|
||||
* @returns Object with counts of added, removed, and modified nodes
|
||||
*/
|
||||
export function calculateNodeChanges(
|
||||
before: WorkflowLike | null | undefined,
|
||||
after: WorkflowLike | null | undefined,
|
||||
): NodeDiffResult {
|
||||
const beforeNodes = before?.nodes ?? [];
|
||||
const afterNodes = after?.nodes ?? [];
|
||||
|
||||
// Filter out nodes without names (shouldn't happen but handle gracefully)
|
||||
const beforeNodesWithNames = beforeNodes.filter((n): n is { name: string } => !!n.name);
|
||||
const afterNodesWithNames = afterNodes.filter((n): n is { name: string } => !!n.name);
|
||||
|
||||
const beforeNames = new Set(beforeNodesWithNames.map((n) => n.name));
|
||||
const afterNames = new Set(afterNodesWithNames.map((n) => n.name));
|
||||
|
||||
const nodes_added = [...afterNames].filter((n) => !beforeNames.has(n)).length;
|
||||
const nodes_removed = [...beforeNames].filter((n) => !afterNames.has(n)).length;
|
||||
|
||||
// For modified: nodes present in both but with different JSON
|
||||
const beforeMap = new Map(beforeNodesWithNames.map((n) => [n.name, JSON.stringify(n)]));
|
||||
const nodes_modified = [...afterNames].filter((name) => {
|
||||
if (!beforeNames.has(name)) return false;
|
||||
const afterNode = afterNodesWithNames.find((n) => n.name === name);
|
||||
return beforeMap.get(name) !== JSON.stringify(afterNode);
|
||||
}).length;
|
||||
|
||||
return { nodes_added, nodes_removed, nodes_modified };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Node Type Parser for CodeWorkflowBuilder
|
||||
*
|
||||
* Provides utilities to work with node types for the code builder:
|
||||
* - Search nodes by name/description
|
||||
* - Get detailed node type information
|
||||
*/
|
||||
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { CodeBuilderNodeSearchEngine } from '../engines/code-builder-node-search-engine';
|
||||
|
||||
export interface ParsedNodeType {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
version: number;
|
||||
isTrigger: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node Type Parser class
|
||||
* Provides efficient access to node type data for the code builder
|
||||
*/
|
||||
export class NodeTypeParser {
|
||||
private nodeTypes: INodeTypeDescription[];
|
||||
private nodeTypeIndex: Map<string, INodeTypeDescription[]>;
|
||||
private searchEngine: CodeBuilderNodeSearchEngine;
|
||||
|
||||
constructor(nodeTypes: INodeTypeDescription[]) {
|
||||
this.nodeTypes = nodeTypes;
|
||||
this.searchEngine = new CodeBuilderNodeSearchEngine(nodeTypes);
|
||||
this.nodeTypeIndex = this.buildIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an index of node types by name for fast lookup
|
||||
*/
|
||||
private buildIndex(): Map<string, INodeTypeDescription[]> {
|
||||
const index = new Map<string, INodeTypeDescription[]>();
|
||||
|
||||
for (const nodeType of this.nodeTypes) {
|
||||
const existing = index.get(nodeType.name) ?? [];
|
||||
existing.push(nodeType);
|
||||
index.set(nodeType.name, existing);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is a trigger
|
||||
* Uses the 'group' property which is the reliable way to identify triggers
|
||||
*/
|
||||
private isTriggerNode(nodeType: INodeTypeDescription): boolean {
|
||||
// Primary check: use the group property (most reliable)
|
||||
if (nodeType.group.includes('trigger')) {
|
||||
return true;
|
||||
}
|
||||
// Fallback for any nodes that might not have group set correctly
|
||||
return (
|
||||
nodeType.name.toLowerCase().includes('trigger') ||
|
||||
nodeType.name.toLowerCase().includes('webhook')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for nodes by name or description
|
||||
* Returns up to `limit` results
|
||||
*/
|
||||
searchNodeTypes(query: string, limit: number = 5): ParsedNodeType[] {
|
||||
const results = this.searchEngine.searchByName(query, limit);
|
||||
|
||||
return results.map((result) => {
|
||||
// Find the full node type to check if it's a trigger
|
||||
const nodeType = this.getNodeType(result.name, result.version);
|
||||
return {
|
||||
id: result.name,
|
||||
displayName: result.displayName,
|
||||
description: result.description,
|
||||
version: result.version,
|
||||
isTrigger: nodeType ? this.isTriggerNode(nodeType) : false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full node type description for a specific node
|
||||
* Returns the latest version if version is not specified
|
||||
*/
|
||||
getNodeType(nodeId: string, version?: number): INodeTypeDescription | null {
|
||||
const versions = this.nodeTypeIndex.get(nodeId);
|
||||
|
||||
if (!versions || versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If version specified, find match (handle both scalar and array versions)
|
||||
if (version !== undefined) {
|
||||
const match = versions.find((v) => {
|
||||
const nodeVersions = Array.isArray(v.version) ? v.version : [v.version];
|
||||
return nodeVersions.includes(version);
|
||||
});
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
// Otherwise, return the latest version (last in array)
|
||||
return versions[versions.length - 1];
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { FakeListChatModel } from '@langchain/core/utils/testing';
|
||||
import { MemorySaver } from '@langchain/langgraph';
|
||||
|
||||
import { SessionManagerService } from '@/session-manager.service';
|
||||
|
||||
import {
|
||||
loadCodeBuilderSession,
|
||||
saveCodeBuilderSession,
|
||||
compactSessionIfNeeded,
|
||||
generateCodeBuilderThreadId,
|
||||
saveSessionMessages,
|
||||
type CodeBuilderSession,
|
||||
} from '../code-builder-session';
|
||||
|
||||
// Mock structured output for the compact chain
|
||||
class MockStructuredLLM extends FakeListChatModel {
|
||||
private readonly structuredResponse: Record<string, unknown>;
|
||||
|
||||
constructor(response: Record<string, unknown>) {
|
||||
super({ responses: ['mock'] });
|
||||
this.structuredResponse = response;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
withStructuredOutput(): any {
|
||||
return {
|
||||
invoke: async () => this.structuredResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('code-builder-session', () => {
|
||||
describe('loadCodeBuilderSession', () => {
|
||||
it('should return empty session for new thread', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'new-thread-123';
|
||||
|
||||
const session = await loadCodeBuilderSession(checkpointer, threadId);
|
||||
|
||||
expect(session).toEqual({ userMessages: [] });
|
||||
});
|
||||
|
||||
it('should return existing session from checkpointer', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'existing-thread-123';
|
||||
|
||||
// Save a session first
|
||||
await saveCodeBuilderSession(checkpointer, threadId, {
|
||||
userMessages: ['message 1', 'message 2'],
|
||||
previousSummary: 'Test summary',
|
||||
});
|
||||
|
||||
// Load it back
|
||||
const session = await loadCodeBuilderSession(checkpointer, threadId);
|
||||
|
||||
expect(session.userMessages).toEqual(['message 1', 'message 2']);
|
||||
expect(session.previousSummary).toBe('Test summary');
|
||||
});
|
||||
|
||||
it('should return empty session if checkpoint has no session data', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'empty-session-thread';
|
||||
|
||||
// Save a checkpoint without session data (mimics a checkpoint from another system)
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
await checkpointer.put(
|
||||
config,
|
||||
{
|
||||
v: 1,
|
||||
id: 'test-id',
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: { otherData: 'some value' },
|
||||
channel_versions: {},
|
||||
versions_seen: {},
|
||||
},
|
||||
{ source: 'update', step: -1, parents: {} },
|
||||
);
|
||||
|
||||
const session = await loadCodeBuilderSession(checkpointer, threadId);
|
||||
|
||||
expect(session).toEqual({ userMessages: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCodeBuilderSession', () => {
|
||||
it('should persist session to checkpointer', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'save-test-thread';
|
||||
|
||||
const sessionToSave: CodeBuilderSession = {
|
||||
userMessages: ['test message 1', 'test message 2'],
|
||||
previousSummary: 'A test summary',
|
||||
};
|
||||
|
||||
await saveCodeBuilderSession(checkpointer, threadId, sessionToSave);
|
||||
|
||||
// Verify it was saved by loading it back
|
||||
const loadedSession = await loadCodeBuilderSession(checkpointer, threadId);
|
||||
|
||||
expect(loadedSession.userMessages).toEqual(['test message 1', 'test message 2']);
|
||||
expect(loadedSession.previousSummary).toBe('A test summary');
|
||||
});
|
||||
|
||||
it('should update existing session', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'update-test-thread';
|
||||
|
||||
// Save initial session
|
||||
await saveCodeBuilderSession(checkpointer, threadId, {
|
||||
userMessages: ['initial message'],
|
||||
});
|
||||
|
||||
// Update session
|
||||
await saveCodeBuilderSession(checkpointer, threadId, {
|
||||
userMessages: ['initial message', 'new message'],
|
||||
previousSummary: 'New summary',
|
||||
});
|
||||
|
||||
const session = await loadCodeBuilderSession(checkpointer, threadId);
|
||||
|
||||
expect(session.userMessages).toEqual(['initial message', 'new message']);
|
||||
expect(session.previousSummary).toBe('New summary');
|
||||
});
|
||||
|
||||
it('should preserve other checkpoint data when updating', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const threadId = 'preserve-data-thread';
|
||||
|
||||
// Save a checkpoint with other data
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
await checkpointer.put(
|
||||
config,
|
||||
{
|
||||
v: 1,
|
||||
id: 'original-id',
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: { existingData: 'should be preserved' },
|
||||
channel_versions: {},
|
||||
versions_seen: {},
|
||||
},
|
||||
{ source: 'update', step: -1, parents: {} },
|
||||
);
|
||||
|
||||
// Save session
|
||||
await saveCodeBuilderSession(checkpointer, threadId, {
|
||||
userMessages: ['test'],
|
||||
});
|
||||
|
||||
// Verify both session and original data exist
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
expect(tuple?.checkpoint.channel_values?.existingData).toBe('should be preserved');
|
||||
expect(tuple?.checkpoint.channel_values?.codeBuilderSession).toEqual({
|
||||
userMessages: ['test'],
|
||||
previousSummary: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactSessionIfNeeded', () => {
|
||||
let fakeLLM: BaseChatModel;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeLLM = new MockStructuredLLM({
|
||||
summary: 'Compacted summary of old messages',
|
||||
key_decisions: ['Decision 1', 'Decision 2'],
|
||||
current_state: 'Current workflow state',
|
||||
next_steps: 'Next steps',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not compact when messages are below threshold', async () => {
|
||||
const session: CodeBuilderSession = {
|
||||
userMessages: ['msg1', 'msg2', 'msg3'],
|
||||
};
|
||||
|
||||
const result = await compactSessionIfNeeded(session, fakeLLM, 20);
|
||||
|
||||
// Should return the same session unchanged
|
||||
expect(result.userMessages).toEqual(['msg1', 'msg2', 'msg3']);
|
||||
expect(result.previousSummary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should compact when messages exceed threshold', async () => {
|
||||
// Create a session with 21 messages (exceeds threshold of 20)
|
||||
const messages = Array.from({ length: 21 }, (_, i) => `message ${i + 1}`);
|
||||
const session: CodeBuilderSession = {
|
||||
userMessages: messages,
|
||||
};
|
||||
|
||||
const result = await compactSessionIfNeeded(session, fakeLLM, 20);
|
||||
|
||||
// Should keep only the most recent 11 messages (21 - 10 = 11)
|
||||
expect(result.userMessages.length).toBe(11);
|
||||
expect(result.userMessages[0]).toBe('message 11');
|
||||
expect(result.userMessages[10]).toBe('message 21');
|
||||
|
||||
// Should have a summary
|
||||
expect(result.previousSummary).toContain('## Previous Conversation Summary');
|
||||
});
|
||||
|
||||
it('should preserve most recent 11 messages when compacting 21', async () => {
|
||||
const messages = Array.from({ length: 21 }, (_, i) => `msg-${i}`);
|
||||
const session: CodeBuilderSession = {
|
||||
userMessages: messages,
|
||||
};
|
||||
|
||||
const result = await compactSessionIfNeeded(session, fakeLLM, 20);
|
||||
|
||||
// Verifies that the 10 oldest are compacted and 11 newest remain
|
||||
expect(result.userMessages).toEqual([
|
||||
'msg-10',
|
||||
'msg-11',
|
||||
'msg-12',
|
||||
'msg-13',
|
||||
'msg-14',
|
||||
'msg-15',
|
||||
'msg-16',
|
||||
'msg-17',
|
||||
'msg-18',
|
||||
'msg-19',
|
||||
'msg-20',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should append to existing summary when compacting', async () => {
|
||||
const messages = Array.from({ length: 21 }, (_, i) => `message ${i + 1}`);
|
||||
const session: CodeBuilderSession = {
|
||||
userMessages: messages,
|
||||
previousSummary: 'Existing summary from earlier compaction',
|
||||
};
|
||||
|
||||
const result = await compactSessionIfNeeded(session, fakeLLM, 20);
|
||||
|
||||
// Should append the new summary to the existing one
|
||||
expect(result.previousSummary).toContain('Existing summary from earlier compaction');
|
||||
expect(result.previousSummary).toContain('---');
|
||||
expect(result.previousSummary).toContain('## Previous Conversation Summary');
|
||||
});
|
||||
|
||||
it('should work with custom maxMessages threshold', async () => {
|
||||
const messages = ['msg1', 'msg2', 'msg3', 'msg4', 'msg5', 'msg6'];
|
||||
const session: CodeBuilderSession = {
|
||||
userMessages: messages,
|
||||
};
|
||||
|
||||
// With max of 5, should compact when we have 6
|
||||
const result = await compactSessionIfNeeded(session, fakeLLM, 5);
|
||||
|
||||
// Should keep messages after the first 10 (but we only have 6, so it keeps 6-10=0, meaning it keeps none from compaction batch)
|
||||
// Actually: MESSAGES_TO_COMPACT is 10, but we only have 6 messages, so it will try to compact first 10 (all of them)
|
||||
// and keep the rest (none)
|
||||
// Let me verify the logic: oldMessages = slice(0, 10) = ['msg1'...'msg6'] (only 6)
|
||||
// recentMessages = slice(10) = [] (empty)
|
||||
// So this test should result in empty userMessages
|
||||
expect(result.userMessages.length).toBeLessThan(6);
|
||||
expect(result.previousSummary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCodeBuilderThreadId', () => {
|
||||
it('should generate consistent thread ID from workflow and user', () => {
|
||||
const threadId1 = generateCodeBuilderThreadId('workflow-123', 'user-456');
|
||||
const threadId2 = generateCodeBuilderThreadId('workflow-123', 'user-456');
|
||||
|
||||
expect(threadId1).toBe(threadId2);
|
||||
expect(threadId1).toBe('code-builder-workflow-123-user-456');
|
||||
});
|
||||
|
||||
it('should generate different IDs for different workflows', () => {
|
||||
const threadId1 = generateCodeBuilderThreadId('workflow-A', 'user-1');
|
||||
const threadId2 = generateCodeBuilderThreadId('workflow-B', 'user-1');
|
||||
|
||||
expect(threadId1).not.toBe(threadId2);
|
||||
});
|
||||
|
||||
it('should generate different IDs for different users', () => {
|
||||
const threadId1 = generateCodeBuilderThreadId('workflow-A', 'user-1');
|
||||
const threadId2 = generateCodeBuilderThreadId('workflow-A', 'user-2');
|
||||
|
||||
expect(threadId1).not.toBe(threadId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSessionMessages', () => {
|
||||
it('should create new checkpoint with provided messages', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
const messages = [
|
||||
new HumanMessage('Create a workflow that sends email'),
|
||||
new AIMessage('I have created the workflow.'),
|
||||
];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages);
|
||||
|
||||
// Verify by reading from the SessionManager thread format
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
expect(tuple).toBeDefined();
|
||||
expect(tuple?.checkpoint.channel_values?.messages).toBeDefined();
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages).toHaveLength(2);
|
||||
|
||||
// Check HumanMessage has messageId added
|
||||
expect(savedMessages[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[0].content).toBe('Create a workflow that sends email');
|
||||
expect(savedMessages[0].additional_kwargs?.messageId).toBeDefined();
|
||||
|
||||
// Check AIMessage
|
||||
expect(savedMessages[1]).toBeInstanceOf(AIMessage);
|
||||
expect(savedMessages[1].content).toBe('I have created the workflow.');
|
||||
});
|
||||
|
||||
it('should store versionId in first HumanMessage additional_kwargs', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
const versionId = 'version-abc';
|
||||
|
||||
const messages = [new HumanMessage('Test message'), new AIMessage('Response')];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages, versionId);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages[0].additional_kwargs?.versionId).toBe(versionId);
|
||||
});
|
||||
|
||||
it('should append to existing messages array', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
// First turn
|
||||
const firstMessages = [new HumanMessage('First message'), new AIMessage('First response')];
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, firstMessages);
|
||||
|
||||
// Second turn
|
||||
const secondMessages = [new HumanMessage('Second message'), new AIMessage('Second response')];
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, secondMessages);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages).toHaveLength(4); // 2 messages from each turn
|
||||
|
||||
expect(savedMessages[0].content).toBe('First message');
|
||||
expect(savedMessages[1].content).toBe('First response');
|
||||
expect(savedMessages[2].content).toBe('Second message');
|
||||
expect(savedMessages[3].content).toBe('Second response');
|
||||
});
|
||||
|
||||
it('should work with HumanMessage only', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
const messages = [new HumanMessage('User message only')];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages).toHaveLength(1);
|
||||
expect(savedMessages[0]).toBeInstanceOf(HumanMessage);
|
||||
});
|
||||
|
||||
it('should use code-builder thread format with -code suffix', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'wf-test';
|
||||
const userId = 'user-test';
|
||||
|
||||
const messages = [new HumanMessage('Test'), new AIMessage('Response')];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages);
|
||||
|
||||
// Verify thread ID uses code-builder format with -code suffix
|
||||
const expectedThreadId = `workflow-${workflowId}-user-${userId}-code`;
|
||||
const config = { configurable: { thread_id: expectedThreadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
expect(tuple).toBeDefined();
|
||||
expect(tuple?.checkpoint.channel_values?.messages).toBeDefined();
|
||||
|
||||
// Verify the old format without -code suffix does NOT have the messages
|
||||
const oldThreadId = `workflow-${workflowId}-user-${userId}`;
|
||||
const oldConfig = { configurable: { thread_id: oldThreadId } };
|
||||
const oldTuple = await checkpointer.getTuple(oldConfig);
|
||||
|
||||
expect(oldTuple).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add messageId to first HumanMessage even when SystemMessage is at index 0', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
// Code-builder messages start with SystemMessage, not HumanMessage
|
||||
const messages = [
|
||||
new SystemMessage('You are a workflow builder assistant.'),
|
||||
new HumanMessage('Create a workflow that sends email'),
|
||||
new AIMessage('I have created the workflow.'),
|
||||
];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
SystemMessage | HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages).toHaveLength(3);
|
||||
|
||||
// SystemMessage should NOT have messageId
|
||||
expect(savedMessages[0]).toBeInstanceOf(SystemMessage);
|
||||
expect(savedMessages[0].additional_kwargs?.messageId).toBeUndefined();
|
||||
|
||||
// First HumanMessage should have messageId even though it's at index 1
|
||||
expect(savedMessages[1]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[1].additional_kwargs?.messageId).toBeDefined();
|
||||
|
||||
// AIMessage should NOT have messageId
|
||||
expect(savedMessages[2]).toBeInstanceOf(AIMessage);
|
||||
expect(savedMessages[2].additional_kwargs?.messageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add messageId to first HumanMessage in each follow-up turn', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
// First turn - with SystemMessage
|
||||
const firstMessages = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('First request'),
|
||||
new AIMessage('First response'),
|
||||
];
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, firstMessages);
|
||||
|
||||
// Second turn - follow-up with SystemMessage
|
||||
const secondMessages = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('Follow-up request'),
|
||||
new AIMessage('Follow-up response'),
|
||||
];
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, secondMessages);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
SystemMessage | HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages).toHaveLength(6); // 3 from each turn
|
||||
|
||||
// First turn's HumanMessage (index 1) should have messageId
|
||||
expect(savedMessages[1]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[1].additional_kwargs?.messageId).toBeDefined();
|
||||
const firstMessageId = savedMessages[1].additional_kwargs?.messageId;
|
||||
|
||||
// Second turn's HumanMessage (index 4) should have a DIFFERENT messageId
|
||||
expect(savedMessages[4]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[4].additional_kwargs?.messageId).toBeDefined();
|
||||
const secondMessageId = savedMessages[4].additional_kwargs?.messageId;
|
||||
|
||||
// Each turn should have unique messageId
|
||||
expect(firstMessageId).not.toBe(secondMessageId);
|
||||
});
|
||||
|
||||
it('should use provided userMessageId instead of generating a random one', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
|
||||
const messages = [new HumanMessage('Create a workflow'), new AIMessage('Done')];
|
||||
|
||||
await saveSessionMessages(
|
||||
checkpointer,
|
||||
workflowId,
|
||||
userId,
|
||||
messages,
|
||||
undefined, // versionId
|
||||
'frontend-id-123', // userMessageId
|
||||
);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
HumanMessage | AIMessage
|
||||
>;
|
||||
expect(savedMessages[0].additional_kwargs?.messageId).toBe('frontend-id-123');
|
||||
});
|
||||
|
||||
it('should add versionId to first HumanMessage when SystemMessage is at index 0', async () => {
|
||||
const checkpointer = new MemorySaver();
|
||||
const workflowId = 'workflow-123';
|
||||
const userId = 'user-456';
|
||||
const versionId = 'version-abc';
|
||||
|
||||
const messages = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('Create workflow'),
|
||||
new AIMessage('Done'),
|
||||
];
|
||||
|
||||
await saveSessionMessages(checkpointer, workflowId, userId, messages, versionId);
|
||||
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, 'code-builder');
|
||||
const config = { configurable: { thread_id: threadId } };
|
||||
const tuple = await checkpointer.getTuple(config);
|
||||
|
||||
const savedMessages = tuple?.checkpoint.channel_values?.messages as Array<
|
||||
SystemMessage | HumanMessage | AIMessage
|
||||
>;
|
||||
|
||||
// versionId should be on the HumanMessage, not SystemMessage
|
||||
expect(savedMessages[0].additional_kwargs?.versionId).toBeUndefined();
|
||||
expect(savedMessages[1].additional_kwargs?.versionId).toBe(versionId);
|
||||
});
|
||||
});
|
||||
});
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for content extractor utilities
|
||||
*/
|
||||
|
||||
import type { AIMessage as AIMessageType, BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
|
||||
import {
|
||||
extractTextContent,
|
||||
extractThinkingContent,
|
||||
pushValidationFeedback,
|
||||
} from '../content-extractors';
|
||||
|
||||
// Helper to create mock AIMessage (for extractText/Thinking tests that don't need instanceof)
|
||||
function createMockMessage(
|
||||
content: string | Array<{ type: string; text?: string; thinking?: string }>,
|
||||
): AIMessageType {
|
||||
return {
|
||||
content,
|
||||
_getType: () => 'ai',
|
||||
} as unknown as AIMessageType;
|
||||
}
|
||||
|
||||
describe('extractTextContent', () => {
|
||||
it('should return string content directly', () => {
|
||||
const message = createMockMessage('Hello world');
|
||||
expect(extractTextContent(message)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should return null for empty string content', () => {
|
||||
const message = createMockMessage('');
|
||||
expect(extractTextContent(message)).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract text from array content blocks', () => {
|
||||
const message = createMockMessage([
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'text', text: 'Second part' },
|
||||
]);
|
||||
expect(extractTextContent(message)).toBe('First part\nSecond part');
|
||||
});
|
||||
|
||||
it('should ignore non-text blocks in array content', () => {
|
||||
const message = createMockMessage([
|
||||
{ type: 'text', text: 'Text content' },
|
||||
{ type: 'tool_use', text: 'should be ignored' },
|
||||
]);
|
||||
expect(extractTextContent(message)).toBe('Text content');
|
||||
});
|
||||
|
||||
it('should return null for array with no text blocks', () => {
|
||||
const message = createMockMessage([{ type: 'tool_use' }]);
|
||||
expect(extractTextContent(message)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty array', () => {
|
||||
const message = createMockMessage([]);
|
||||
expect(extractTextContent(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractThinkingContent', () => {
|
||||
it('should extract <thinking> tags from text content', () => {
|
||||
const message = createMockMessage(
|
||||
'Some preamble <thinking>My thinking here</thinking> some more',
|
||||
);
|
||||
expect(extractThinkingContent(message)).toBe('My thinking here');
|
||||
});
|
||||
|
||||
it('should extract multiple <thinking> blocks', () => {
|
||||
const message = createMockMessage(
|
||||
'<thinking>First thought</thinking> middle <thinking>Second thought</thinking>',
|
||||
);
|
||||
expect(extractThinkingContent(message)).toBe('First thought\n\nSecond thought');
|
||||
});
|
||||
|
||||
it('should return null when no thinking tags present', () => {
|
||||
const message = createMockMessage('Just regular content');
|
||||
expect(extractThinkingContent(message)).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract from thinking content blocks in array', () => {
|
||||
const message = createMockMessage([
|
||||
{ type: 'text', text: 'Some text' },
|
||||
{ type: 'thinking', thinking: 'Extended thinking content' },
|
||||
]);
|
||||
expect(extractThinkingContent(message)).toBe('Extended thinking content');
|
||||
});
|
||||
|
||||
it('should return null for empty content', () => {
|
||||
const message = createMockMessage('');
|
||||
expect(extractThinkingContent(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushValidationFeedback', () => {
|
||||
it('should convert string content to array with text and tool_use blocks', () => {
|
||||
const aiMessage = new AIMessage({ content: 'Some response' });
|
||||
const messages: BaseMessage[] = [aiMessage];
|
||||
|
||||
pushValidationFeedback(messages, 'Validation feedback');
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toBe('Validation feedback');
|
||||
// String content should be converted to array with text + tool_use blocks
|
||||
const content = (messages[0] as AIMessage).content as Array<Record<string, unknown>>;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content).toHaveLength(2);
|
||||
expect(content[0]).toMatchObject({ type: 'text', text: 'Some response' });
|
||||
expect(content[1]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
name: 'validate_workflow',
|
||||
input: {},
|
||||
});
|
||||
expect(content[1].id).toMatch(/^auto-validate-/);
|
||||
// ToolMessage should reference same ID
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
expect(toolMessage.tool_call_id).toBe(content[1].id);
|
||||
});
|
||||
|
||||
it('should inject tool_use block into content array when AIMessage has array content', () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'some thought' },
|
||||
{ type: 'text', text: 'Some response' },
|
||||
],
|
||||
});
|
||||
const messages: BaseMessage[] = [aiMessage];
|
||||
|
||||
pushValidationFeedback(messages, 'Validation feedback');
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
// Should have tool_calls set
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('validate_workflow');
|
||||
// Should have tool_use block in content array
|
||||
const content = (messages[0] as AIMessage).content as Array<Record<string, unknown>>;
|
||||
expect(content).toHaveLength(3);
|
||||
expect(content[2]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
name: 'validate_workflow',
|
||||
input: {},
|
||||
});
|
||||
expect(content[2].id).toMatch(/^auto-validate-/);
|
||||
// ToolMessage should reference same ID
|
||||
const toolMessage = messages[1] as ToolMessage;
|
||||
expect(toolMessage.tool_call_id).toBe(content[2].id);
|
||||
});
|
||||
|
||||
it('should append ToolMessage even when last message is not AIMessage', () => {
|
||||
const toolMsg = new ToolMessage({
|
||||
tool_call_id: 'existing',
|
||||
content: 'previous result',
|
||||
});
|
||||
const messages: BaseMessage[] = [toolMsg];
|
||||
|
||||
pushValidationFeedback(messages, 'Validation feedback');
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1]).toBeInstanceOf(ToolMessage);
|
||||
expect((messages[1] as ToolMessage).content).toBe('Validation feedback');
|
||||
});
|
||||
|
||||
it('should append to existing tool_calls when AIMessage already has tool_calls', () => {
|
||||
const aiMessage = new AIMessage({ content: 'response' });
|
||||
aiMessage.tool_calls = [{ id: 'existing-id', name: 'some_tool', args: {} }];
|
||||
const messages: BaseMessage[] = [aiMessage];
|
||||
|
||||
pushValidationFeedback(messages, 'feedback');
|
||||
|
||||
expect((messages[0] as AIMessage).tool_calls).toHaveLength(2);
|
||||
expect((messages[0] as AIMessage).tool_calls![0].name).toBe('some_tool');
|
||||
expect((messages[0] as AIMessage).tool_calls![1].name).toBe('validate_workflow');
|
||||
});
|
||||
});
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
extractModeDiscriminator,
|
||||
extractOperationOnlyDiscriminator,
|
||||
type ModeInfo,
|
||||
} from '../discriminator-utils';
|
||||
|
||||
// Mock vector store node with mode discriminator that includes outputConnectionType
|
||||
const mockVectorStoreNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.vectorStorePinecone',
|
||||
displayName: 'Pinecone Vector Store',
|
||||
description: 'Work with your data in Pinecone Vector Store',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Pinecone Vector Store' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'load',
|
||||
description: 'Get many ranked documents from vector store for query',
|
||||
},
|
||||
{
|
||||
name: 'Insert Documents',
|
||||
value: 'insert',
|
||||
description: 'Insert documents into vector store',
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
|
||||
value: 'retrieve',
|
||||
description:
|
||||
'Retrieve documents from vector store to be used as vector store with AI nodes',
|
||||
outputConnectionType: NodeConnectionTypes.AiVectorStore,
|
||||
},
|
||||
{
|
||||
name: 'Retrieve Documents (As Tool for AI Agent)',
|
||||
value: 'retrieve-as-tool',
|
||||
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
|
||||
outputConnectionType: NodeConnectionTypes.AiTool,
|
||||
},
|
||||
{
|
||||
name: 'Update Documents',
|
||||
value: 'update',
|
||||
description: 'Update documents in vector store by ID',
|
||||
},
|
||||
],
|
||||
default: 'load',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Simple Code node without outputConnectionType
|
||||
const mockCodeNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.code',
|
||||
displayName: 'Code',
|
||||
description: 'Run custom JavaScript code',
|
||||
group: ['transform'],
|
||||
version: 2,
|
||||
defaults: { name: 'Code' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Run Once for All Items', value: 'runOnceForAllItems' },
|
||||
{ name: 'Run Once for Each Item', value: 'runOnceForEachItem' },
|
||||
],
|
||||
default: 'runOnceForAllItems',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('extractModeDiscriminator', () => {
|
||||
describe('basic mode extraction', () => {
|
||||
it('should extract mode values from a node with mode property', () => {
|
||||
const result = extractModeDiscriminator(mockCodeNode, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.modes).toHaveLength(2);
|
||||
expect(result!.modes.map((m: ModeInfo) => m.value)).toContain('runOnceForAllItems');
|
||||
expect(result!.modes.map((m: ModeInfo) => m.value)).toContain('runOnceForEachItem');
|
||||
});
|
||||
|
||||
it('should return null for nodes without mode property', () => {
|
||||
const nodeWithoutMode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
description: 'Makes HTTP requests',
|
||||
group: ['transform'],
|
||||
version: 4,
|
||||
defaults: { name: 'HTTP Request' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = extractModeDiscriminator(nodeWithoutMode, 4);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extended mode info with displayName and outputConnectionType', () => {
|
||||
it('should include displayName for each mode', () => {
|
||||
const result = extractModeDiscriminator(mockVectorStoreNode, 1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.modes).toHaveLength(5);
|
||||
|
||||
const loadMode = result!.modes.find((m: ModeInfo) => m.value === 'load');
|
||||
expect(loadMode).toBeDefined();
|
||||
expect(loadMode!.displayName).toBe('Get Many');
|
||||
|
||||
const retrieveMode = result!.modes.find((m: ModeInfo) => m.value === 'retrieve');
|
||||
expect(retrieveMode).toBeDefined();
|
||||
expect(retrieveMode!.displayName).toBe('Retrieve Documents (As Vector Store for Chain/Tool)');
|
||||
|
||||
const retrieveAsToolMode = result!.modes.find(
|
||||
(m: ModeInfo) => m.value === 'retrieve-as-tool',
|
||||
);
|
||||
expect(retrieveAsToolMode).toBeDefined();
|
||||
expect(retrieveAsToolMode!.displayName).toBe('Retrieve Documents (As Tool for AI Agent)');
|
||||
});
|
||||
|
||||
it('should include outputConnectionType when present', () => {
|
||||
const result = extractModeDiscriminator(mockVectorStoreNode, 1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// retrieve mode should have AiVectorStore connection type
|
||||
const retrieveMode = result!.modes.find((m: ModeInfo) => m.value === 'retrieve');
|
||||
expect(retrieveMode).toBeDefined();
|
||||
expect(retrieveMode!.outputConnectionType).toBe(NodeConnectionTypes.AiVectorStore);
|
||||
|
||||
// retrieve-as-tool mode should have AiTool connection type
|
||||
const retrieveAsToolMode = result!.modes.find(
|
||||
(m: ModeInfo) => m.value === 'retrieve-as-tool',
|
||||
);
|
||||
expect(retrieveAsToolMode).toBeDefined();
|
||||
expect(retrieveAsToolMode!.outputConnectionType).toBe(NodeConnectionTypes.AiTool);
|
||||
});
|
||||
|
||||
it('should have undefined outputConnectionType for modes without it', () => {
|
||||
const result = extractModeDiscriminator(mockVectorStoreNode, 1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
// load, insert, update modes should NOT have outputConnectionType
|
||||
const loadMode = result!.modes.find((m: ModeInfo) => m.value === 'load');
|
||||
expect(loadMode).toBeDefined();
|
||||
expect(loadMode!.outputConnectionType).toBeUndefined();
|
||||
|
||||
const insertMode = result!.modes.find((m: ModeInfo) => m.value === 'insert');
|
||||
expect(insertMode).toBeDefined();
|
||||
expect(insertMode!.outputConnectionType).toBeUndefined();
|
||||
|
||||
const updateMode = result!.modes.find((m: ModeInfo) => m.value === 'update');
|
||||
expect(updateMode).toBeDefined();
|
||||
expect(updateMode!.outputConnectionType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should work with Code node that has no outputConnectionType on any mode', () => {
|
||||
const result = extractModeDiscriminator(mockCodeNode, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.modes).toHaveLength(2);
|
||||
|
||||
// All modes should have displayName but no outputConnectionType
|
||||
for (const mode of result!.modes) {
|
||||
expect(mode.displayName).toBeDefined();
|
||||
expect(mode.outputConnectionType).toBeUndefined();
|
||||
}
|
||||
|
||||
const runOnceForAll = result!.modes.find((m: ModeInfo) => m.value === 'runOnceForAllItems');
|
||||
expect(runOnceForAll!.displayName).toBe('Run Once for All Items');
|
||||
});
|
||||
});
|
||||
|
||||
describe('description and builderHint extraction', () => {
|
||||
it('should include description when present on mode options', () => {
|
||||
const result = extractModeDiscriminator(mockVectorStoreNode, 1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const loadMode = result!.modes.find((m: ModeInfo) => m.value === 'load');
|
||||
expect(loadMode!.description).toBe('Get many ranked documents from vector store for query');
|
||||
|
||||
const insertMode = result!.modes.find((m: ModeInfo) => m.value === 'insert');
|
||||
expect(insertMode!.description).toBe('Insert documents into vector store');
|
||||
});
|
||||
|
||||
it('should include builderHint when present on mode options', () => {
|
||||
const nodeWithBuilderHints: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.testNodeWithHints',
|
||||
displayName: 'Test Node',
|
||||
description: 'A test node with builderHints',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Test Node' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Mode A',
|
||||
value: 'modeA',
|
||||
description: 'Description of mode A',
|
||||
builderHint: { message: 'Use mode A when you want to do X' },
|
||||
},
|
||||
{
|
||||
name: 'Mode B',
|
||||
value: 'modeB',
|
||||
description: 'Description of mode B',
|
||||
// No builderHint - should be undefined
|
||||
},
|
||||
],
|
||||
default: 'modeA',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = extractModeDiscriminator(nodeWithBuilderHints, 1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const modeA = result!.modes.find((m: ModeInfo) => m.value === 'modeA');
|
||||
expect(modeA!.description).toBe('Description of mode A');
|
||||
expect(modeA!.builderHint).toEqual({ message: 'Use mode A when you want to do X' });
|
||||
|
||||
const modeB = result!.modes.find((m: ModeInfo) => m.value === 'modeB');
|
||||
expect(modeB!.description).toBe('Description of mode B');
|
||||
expect(modeB!.builderHint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Mock operation-only node (like Remove Duplicates V2)
|
||||
const mockOperationOnlyNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.removeDuplicates',
|
||||
displayName: 'Remove Duplicates',
|
||||
description: 'Delete items with matching field values',
|
||||
group: ['transform'],
|
||||
version: 2,
|
||||
defaults: { name: 'Remove Duplicates' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Remove Items Repeated Within Current Input',
|
||||
value: 'removeDuplicateInputItems',
|
||||
description: 'Remove duplicates from incoming items',
|
||||
},
|
||||
{
|
||||
name: 'Remove Items Processed in Previous Executions',
|
||||
value: 'removeItemsSeenInPreviousExecutions',
|
||||
description: 'Deduplicate items already seen in previous executions',
|
||||
},
|
||||
{
|
||||
name: 'Clear Deduplication History',
|
||||
value: 'clearDeduplicationHistory',
|
||||
description: 'Wipe the store of previous items',
|
||||
},
|
||||
],
|
||||
default: 'removeDuplicateInputItems',
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
default: 'cleanDatabase',
|
||||
displayOptions: {
|
||||
show: {
|
||||
operation: ['clearDeduplicationHistory'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Clean Database',
|
||||
value: 'cleanDatabase',
|
||||
description: 'Clear all values stored for a key in the database',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock node with both resource and operation
|
||||
const mockResourceAndOperationNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.googleSheets',
|
||||
displayName: 'Google Sheets',
|
||||
description: 'Read, update and write data to Google Sheets',
|
||||
group: ['transform'],
|
||||
version: 4,
|
||||
defaults: { name: 'Google Sheets' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [{ name: 'Sheet', value: 'sheet' }],
|
||||
default: 'sheet',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Append Row', value: 'append' },
|
||||
{ name: 'Read Rows', value: 'read' },
|
||||
],
|
||||
default: 'append',
|
||||
displayOptions: { show: { resource: ['sheet'] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('extractOperationOnlyDiscriminator', () => {
|
||||
it('should extract operations from a node with operation but no resource', () => {
|
||||
const result = extractOperationOnlyDiscriminator(mockOperationOnlyNode, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.operations).toHaveLength(3);
|
||||
expect(result!.operations.map((op) => op.value)).toEqual([
|
||||
'removeDuplicateInputItems',
|
||||
'removeItemsSeenInPreviousExecutions',
|
||||
'clearDeduplicationHistory',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include displayName and description for each operation', () => {
|
||||
const result = extractOperationOnlyDiscriminator(mockOperationOnlyNode, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const firstOp = result!.operations.find((op) => op.value === 'removeDuplicateInputItems');
|
||||
expect(firstOp!.displayName).toBe('Remove Items Repeated Within Current Input');
|
||||
expect(firstOp!.description).toBe('Remove duplicates from incoming items');
|
||||
});
|
||||
|
||||
it('should return null for nodes with both resource and operation', () => {
|
||||
const result = extractOperationOnlyDiscriminator(mockResourceAndOperationNode, 4);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for nodes without operation property', () => {
|
||||
const result = extractOperationOnlyDiscriminator(mockCodeNode, 2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for nodes with no properties', () => {
|
||||
const emptyNode: INodeTypeDescription = {
|
||||
name: 'n8n-nodes-base.empty',
|
||||
displayName: 'Empty',
|
||||
description: 'Empty node',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
defaults: { name: 'Empty' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
|
||||
const result = extractOperationOnlyDiscriminator(emptyNode, 1);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractModeDiscriminator - subordinate mode guard', () => {
|
||||
it('should return null for mode properties subordinate to operation', () => {
|
||||
const result = extractModeDiscriminator(mockOperationOnlyNode, 2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should still extract top-level mode properties without displayOptions dependencies', () => {
|
||||
const result = extractModeDiscriminator(mockCodeNode, 2);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.modes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { extractWorkflowCode, stripImportStatements, SDK_IMPORT_STATEMENT } from '../extract-code';
|
||||
|
||||
describe('extractWorkflowCode', () => {
|
||||
it('should extract code from typescript code block', () => {
|
||||
const response = `Here's the workflow:
|
||||
|
||||
\`\`\`typescript
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: { name: 'Start' }
|
||||
});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start = trigger');
|
||||
expect(code).toContain("export default workflow('test', 'Test').add(start);");
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should extract code from ts code block', () => {
|
||||
const response = `\`\`\`ts
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start = trigger');
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should extract code from javascript code block', () => {
|
||||
const response = `\`\`\`javascript
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start = trigger');
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should extract code from js code block', () => {
|
||||
const response = `\`\`\`js
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start = trigger');
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should extract code from code block without language tag', () => {
|
||||
const response = `\`\`\`
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start');
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should return trimmed response if no code block found', () => {
|
||||
const response = ` const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start); `;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toBe(`const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);`);
|
||||
});
|
||||
|
||||
it('should handle response with text before and after code block', () => {
|
||||
const response = `Let me generate that workflow for you.
|
||||
|
||||
\`\`\`typescript
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: { name: 'Start', position: [240, 300] }
|
||||
});
|
||||
|
||||
export default workflow('hello-world', 'Hello World')
|
||||
.add(start);
|
||||
\`\`\`
|
||||
|
||||
This workflow will start manually when triggered.`;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toContain('const start = trigger');
|
||||
expect(code).toContain('.add(start);');
|
||||
expect(code).not.toContain('Let me generate');
|
||||
expect(code).not.toContain('This workflow will');
|
||||
expect(code).not.toContain('```');
|
||||
});
|
||||
|
||||
it('should preserve newlines and indentation within code block', () => {
|
||||
const response = `\`\`\`typescript
|
||||
const start = trigger({
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
version: 1,
|
||||
config: {
|
||||
name: 'Start',
|
||||
position: [240, 300]
|
||||
}
|
||||
});
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
// Should contain proper indentation
|
||||
expect(code).toContain(' type:');
|
||||
expect(code).toContain(' config:');
|
||||
expect(code).toContain(' name:');
|
||||
});
|
||||
|
||||
it('should handle empty code block', () => {
|
||||
const response = `\`\`\`typescript
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).toBe('');
|
||||
});
|
||||
|
||||
it('should strip the SDK import statement from code blocks', () => {
|
||||
const response = `\`\`\`typescript
|
||||
import { workflow, node, trigger } from '@n8n/workflow-sdk';
|
||||
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).not.toContain('import');
|
||||
expect(code).toContain('const start = trigger');
|
||||
});
|
||||
|
||||
it('should strip multi-line imports from code blocks', () => {
|
||||
const response = `\`\`\`typescript
|
||||
import {
|
||||
workflow,
|
||||
node,
|
||||
trigger
|
||||
} from '@n8n/workflow-sdk';
|
||||
|
||||
const start = trigger({...});
|
||||
\`\`\``;
|
||||
|
||||
const code = extractWorkflowCode(response);
|
||||
expect(code).not.toContain('import');
|
||||
expect(code).toContain('const start = trigger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripImportStatements', () => {
|
||||
it('should strip the SDK import statement', () => {
|
||||
const code = `${SDK_IMPORT_STATEMENT}
|
||||
|
||||
const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).not.toContain('import');
|
||||
expect(result).toContain('const start');
|
||||
});
|
||||
|
||||
it('should strip multiple import statements', () => {
|
||||
const code = `import { workflow } from '@n8n/workflow-sdk';
|
||||
import { something } from 'other';
|
||||
|
||||
const start = trigger({...});`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).not.toContain('import');
|
||||
expect(result).toContain('const start');
|
||||
});
|
||||
|
||||
it('should strip side-effect imports', () => {
|
||||
const code = `import 'some-polyfill';
|
||||
import { workflow } from '@n8n/workflow-sdk';
|
||||
|
||||
const start = trigger({...});`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).not.toContain('import');
|
||||
expect(result).toContain('const start');
|
||||
});
|
||||
|
||||
it('should strip default imports', () => {
|
||||
const code = `import sdk from '@n8n/workflow-sdk';
|
||||
|
||||
const start = trigger({...});`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).not.toContain('import');
|
||||
expect(result).toContain('const start');
|
||||
});
|
||||
|
||||
it('should strip namespace imports', () => {
|
||||
const code = `import * as sdk from '@n8n/workflow-sdk';
|
||||
|
||||
const start = trigger({...});`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).not.toContain('import');
|
||||
expect(result).toContain('const start');
|
||||
});
|
||||
|
||||
it('should handle code with no imports', () => {
|
||||
const code = `const start = trigger({...});
|
||||
export default workflow('test', 'Test').add(start);`;
|
||||
|
||||
const result = stripImportStatements(code);
|
||||
expect(result).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK_IMPORT_STATEMENT', () => {
|
||||
it('should contain all core functions', () => {
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('workflow');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('node');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('trigger');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('sticky');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('placeholder');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('newCredential');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('expr');
|
||||
});
|
||||
|
||||
it('should contain control flow functions', () => {
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('ifElse');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('switchCase');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('merge');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('splitInBatches');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('nextBatch');
|
||||
});
|
||||
|
||||
it('should contain AI functions', () => {
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('languageModel');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('memory');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('tool');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('outputParser');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('embedding');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('embeddings');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('vectorStore');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('retriever');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('documentLoader');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('textSplitter');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('reranker');
|
||||
expect(SDK_IMPORT_STATEMENT).toContain('fromAi');
|
||||
});
|
||||
|
||||
it('should be a valid import statement', () => {
|
||||
expect(SDK_IMPORT_STATEMENT).toMatch(/^import \{.*\} from '@n8n\/workflow-sdk';$/);
|
||||
});
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { WarningTracker } from '../../state/warning-tracker';
|
||||
import type { ValidationWarning } from '../../types';
|
||||
import { formatWarnings } from '../format-warnings';
|
||||
|
||||
describe('formatWarnings', () => {
|
||||
it('should format warnings without tracker', () => {
|
||||
const warnings: ValidationWarning[] = [
|
||||
{ code: 'WARN001', message: 'Missing parameter' },
|
||||
{ code: 'WARN002', message: 'Invalid connection' },
|
||||
];
|
||||
|
||||
const result = formatWarnings(warnings);
|
||||
|
||||
expect(result).toBe('- [WARN001] Missing parameter\n- [WARN002] Invalid connection');
|
||||
});
|
||||
|
||||
it('should return empty string for empty array', () => {
|
||||
expect(formatWarnings([])).toBe('');
|
||||
});
|
||||
|
||||
it('should annotate pre-existing warnings when tracker is provided', () => {
|
||||
const tracker = new WarningTracker();
|
||||
const preExisting: ValidationWarning = {
|
||||
code: 'WARN001',
|
||||
message: 'Missing parameter',
|
||||
nodeName: 'HTTP Request',
|
||||
};
|
||||
const newWarning: ValidationWarning = {
|
||||
code: 'WARN002',
|
||||
message: 'Invalid connection',
|
||||
};
|
||||
|
||||
tracker.markAsPreExisting([preExisting]);
|
||||
|
||||
const result = formatWarnings([preExisting, newWarning], tracker);
|
||||
|
||||
expect(result).toBe(
|
||||
'- [WARN001] [pre-existing] Missing parameter\n- [WARN002] Invalid connection',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not annotate when tracker has no pre-existing warnings', () => {
|
||||
const tracker = new WarningTracker();
|
||||
const warnings: ValidationWarning[] = [{ code: 'WARN001', message: 'Missing parameter' }];
|
||||
|
||||
const result = formatWarnings(warnings, tracker);
|
||||
|
||||
expect(result).toBe('- [WARN001] Missing parameter');
|
||||
});
|
||||
|
||||
it('should handle single warning', () => {
|
||||
const warnings: ValidationWarning[] = [{ code: 'ERR001', message: 'Some error' }];
|
||||
|
||||
const result = formatWarnings(warnings);
|
||||
|
||||
expect(result).toBe('- [ERR001] Some error');
|
||||
});
|
||||
|
||||
it('should annotate all pre-existing warnings in a mixed list', () => {
|
||||
const tracker = new WarningTracker();
|
||||
const w1: ValidationWarning = { code: 'WARN001', message: 'First', nodeName: 'Node1' };
|
||||
const w2: ValidationWarning = { code: 'WARN002', message: 'Second', nodeName: 'Node2' };
|
||||
const w3: ValidationWarning = { code: 'WARN003', message: 'Third' };
|
||||
|
||||
tracker.markAsPreExisting([w1, w3]);
|
||||
|
||||
const result = formatWarnings([w1, w2, w3], tracker);
|
||||
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('- [WARN001] [pre-existing] First');
|
||||
expect(lines[1]).toBe('- [WARN002] Second');
|
||||
expect(lines[2]).toBe('- [WARN003] [pre-existing] Third');
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for LLM Response Processor
|
||||
*/
|
||||
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
|
||||
import { processLlmResponse } from '../llm-response-processor';
|
||||
|
||||
describe('processLlmResponse', () => {
|
||||
it('should extract token usage from response metadata', () => {
|
||||
const response = new AIMessage({
|
||||
content: 'Hello',
|
||||
response_metadata: {
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.inputTokens).toBe(100);
|
||||
expect(result.outputTokens).toBe(50);
|
||||
});
|
||||
|
||||
it('should default to 0 tokens when metadata is missing', () => {
|
||||
const response = new AIMessage({
|
||||
content: 'Hello',
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.inputTokens).toBe(0);
|
||||
expect(result.outputTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('should extract text content from string response', () => {
|
||||
const response = new AIMessage({
|
||||
content: 'Hello world',
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.textContent).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should extract text content from array response', () => {
|
||||
const response = new AIMessage({
|
||||
content: [{ type: 'text', text: 'Hello from array' }],
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.textContent).toBe('Hello from array');
|
||||
});
|
||||
|
||||
it('should return null text content for empty response', () => {
|
||||
const response = new AIMessage({
|
||||
content: '',
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.textContent).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract thinking content when present', () => {
|
||||
const response = new AIMessage({
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'My thought process' },
|
||||
{ type: 'text', text: 'Final answer' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.thinkingContent).toBe('My thought process');
|
||||
expect(result.textContent).toBe('Final answer');
|
||||
});
|
||||
|
||||
it('should return null thinking content when not present', () => {
|
||||
const response = new AIMessage({
|
||||
content: 'Just text',
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.thinkingContent).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect when there are tool calls', () => {
|
||||
const response = new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'search_nodes',
|
||||
args: { query: 'test' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.hasToolCalls).toBe(true);
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls[0].name).toBe('search_nodes');
|
||||
});
|
||||
|
||||
it('should return empty tool calls when none present', () => {
|
||||
const response = new AIMessage({
|
||||
content: 'No tools',
|
||||
});
|
||||
|
||||
const result = processLlmResponse(response);
|
||||
|
||||
expect(result.hasToolCalls).toBe(false);
|
||||
expect(result.toolCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Tests for node-diff utility
|
||||
*/
|
||||
|
||||
import { calculateNodeChanges } from '../node-diff';
|
||||
|
||||
describe('calculateNodeChanges', () => {
|
||||
describe('when comparing workflows', () => {
|
||||
it('should return zeros for identical workflows', () => {
|
||||
const before = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
],
|
||||
};
|
||||
const after = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result).toEqual({
|
||||
nodes_added: 0,
|
||||
nodes_removed: 0,
|
||||
nodes_modified: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect added nodes', () => {
|
||||
const before = {
|
||||
nodes: [{ name: 'Node1', type: 'n8n-nodes-base.start' }],
|
||||
};
|
||||
const after = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
{ name: 'Node3', type: 'n8n-nodes-base.set' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result.nodes_added).toBe(2);
|
||||
expect(result.nodes_removed).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect removed nodes', () => {
|
||||
const before = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
{ name: 'Node3', type: 'n8n-nodes-base.set' },
|
||||
],
|
||||
};
|
||||
const after = {
|
||||
nodes: [{ name: 'Node1', type: 'n8n-nodes-base.start' }],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result.nodes_added).toBe(0);
|
||||
expect(result.nodes_removed).toBe(2);
|
||||
});
|
||||
|
||||
it('should detect modified nodes', () => {
|
||||
const before = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest', parameters: { url: 'old' } },
|
||||
],
|
||||
};
|
||||
const after = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest', parameters: { url: 'new' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result.nodes_added).toBe(0);
|
||||
expect(result.nodes_removed).toBe(0);
|
||||
expect(result.nodes_modified).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle all three types of changes simultaneously', () => {
|
||||
const before = {
|
||||
nodes: [
|
||||
{ name: 'Keep', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Remove', type: 'n8n-nodes-base.set' },
|
||||
{ name: 'Modify', type: 'n8n-nodes-base.httpRequest', parameters: { url: 'old' } },
|
||||
],
|
||||
};
|
||||
const after = {
|
||||
nodes: [
|
||||
{ name: 'Keep', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Add', type: 'n8n-nodes-base.code' },
|
||||
{ name: 'Modify', type: 'n8n-nodes-base.httpRequest', parameters: { url: 'new' } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result.nodes_added).toBe(1);
|
||||
expect(result.nodes_removed).toBe(1);
|
||||
expect(result.nodes_modified).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null before workflow', () => {
|
||||
const after = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(null, after);
|
||||
|
||||
expect(result.nodes_added).toBe(2);
|
||||
expect(result.nodes_removed).toBe(0);
|
||||
expect(result.nodes_modified).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null after workflow', () => {
|
||||
const before = {
|
||||
nodes: [
|
||||
{ name: 'Node1', type: 'n8n-nodes-base.start' },
|
||||
{ name: 'Node2', type: 'n8n-nodes-base.httpRequest' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = calculateNodeChanges(before, null);
|
||||
|
||||
expect(result.nodes_added).toBe(0);
|
||||
expect(result.nodes_removed).toBe(2);
|
||||
expect(result.nodes_modified).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined workflows', () => {
|
||||
const result = calculateNodeChanges(undefined, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
nodes_added: 0,
|
||||
nodes_removed: 0,
|
||||
nodes_modified: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty node arrays', () => {
|
||||
const before = { nodes: [] };
|
||||
const after = { nodes: [] };
|
||||
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result).toEqual({
|
||||
nodes_added: 0,
|
||||
nodes_removed: 0,
|
||||
nodes_modified: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nodes without names', () => {
|
||||
const before = {
|
||||
nodes: [{ name: 'Node1' }, { name: undefined }, { type: 'some-type' }],
|
||||
};
|
||||
const after = {
|
||||
nodes: [{ name: 'Node1' }, { name: 'Node2' }],
|
||||
};
|
||||
|
||||
// Nodes without names should be filtered out
|
||||
const result = calculateNodeChanges(before, after);
|
||||
|
||||
expect(result.nodes_added).toBe(1); // Node2
|
||||
expect(result.nodes_removed).toBe(0); // The node without name is filtered
|
||||
expect(result.nodes_modified).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { NodeTypeParser } from '../node-type-parser';
|
||||
|
||||
// Mock node with array version (like OpenAI V2)
|
||||
const mockOpenAiNode: INodeTypeDescription = {
|
||||
name: '@n8n/n8n-nodes-langchain.openAi',
|
||||
displayName: 'OpenAI',
|
||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||
group: ['transform'],
|
||||
version: [2, 2.1], // Array version - this is the key part of the test
|
||||
defaults: { name: 'OpenAI' },
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{ name: 'Text', value: 'text' },
|
||||
{ name: 'Image', value: 'image' },
|
||||
],
|
||||
default: 'text',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['text'] } },
|
||||
options: [
|
||||
{ name: 'Message a Model', value: 'response' },
|
||||
{ name: 'Classify Text', value: 'classify' },
|
||||
],
|
||||
default: 'response',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: { show: { resource: ['image'] } },
|
||||
options: [{ name: 'Generate', value: 'generate' }],
|
||||
default: 'generate',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('NodeTypeParser', () => {
|
||||
describe('getNodeType with array versions', () => {
|
||||
it('should find node when version is in the array', () => {
|
||||
const parser = new NodeTypeParser([mockOpenAiNode]);
|
||||
|
||||
// Node has version: [2, 2.1], should find it when searching for 2.1
|
||||
const result = parser.getNodeType('@n8n/n8n-nodes-langchain.openAi', 2.1);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('@n8n/n8n-nodes-langchain.openAi');
|
||||
expect(result?.displayName).toBe('OpenAI');
|
||||
});
|
||||
|
||||
it('should find node when searching for any version in the array', () => {
|
||||
const parser = new NodeTypeParser([mockOpenAiNode]);
|
||||
|
||||
// Should find for version 2 (first in array)
|
||||
const result2 = parser.getNodeType('@n8n/n8n-nodes-langchain.openAi', 2);
|
||||
expect(result2).not.toBeNull();
|
||||
|
||||
// Should find for version 2.1 (second in array)
|
||||
const result21 = parser.getNodeType('@n8n/n8n-nodes-langchain.openAi', 2.1);
|
||||
expect(result21).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for version not in array', () => {
|
||||
const parser = new NodeTypeParser([mockOpenAiNode]);
|
||||
|
||||
// Node has version: [2, 2.1], should NOT find version 1
|
||||
const result = parser.getNodeType('@n8n/n8n-nodes-langchain.openAi', 1);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,5 @@ export * from './ai-workflow-builder-agent.service';
|
||||
export type * from './types';
|
||||
export * from './workflow-state';
|
||||
export { resolveConnections } from '@/validation/utils/resolve-connections';
|
||||
export { CodeBuilderAgent, type CodeBuilderAgentConfig } from './code-builder';
|
||||
export { CodeWorkflowBuilder, type CodeWorkflowBuilderConfig } from './code-builder';
|
||||
|
||||
@@ -60,7 +60,7 @@ export const anthropicClaudeSonnet45Think = async (config: LLMProviderConfig) =>
|
||||
maxTokens: MAX_OUTPUT_TOKENS,
|
||||
anthropicApiUrl: config.baseUrl,
|
||||
thinking: {
|
||||
budget_tokens: 2056,
|
||||
budget_tokens: 10000,
|
||||
type: 'enabled',
|
||||
},
|
||||
clientOptions: {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
isSubgraphPhase,
|
||||
} from './types/coordination';
|
||||
import { getNextPhaseFromLog, hasErrorInLog } from './utils/coordination-log';
|
||||
import { sanitizeLlmErrorMessage } from './utils/error-sanitizer';
|
||||
import { processOperations } from './utils/operations-processor';
|
||||
import {
|
||||
determineStateAction,
|
||||
@@ -142,8 +143,9 @@ function createSubgraphNodeHandler<
|
||||
throw error;
|
||||
}
|
||||
logger?.error(`[${name}] ERROR:`, { error });
|
||||
const errorMessage =
|
||||
const rawErrorMessage =
|
||||
error instanceof Error ? error.message : `An error occurred in ${name}: ${String(error)}`;
|
||||
const userFacingMessage = sanitizeLlmErrorMessage(error);
|
||||
|
||||
// Route to responder to report error (terminal)
|
||||
// Include in_progress entry for timing even on errors
|
||||
@@ -152,7 +154,7 @@ function createSubgraphNodeHandler<
|
||||
nextPhase: 'responder',
|
||||
messages: [
|
||||
new HumanMessage({
|
||||
content: `Error in ${name}: ${errorMessage}`,
|
||||
content: `Error in ${name}: ${userFacingMessage}`,
|
||||
name: 'system_error',
|
||||
}),
|
||||
],
|
||||
@@ -168,10 +170,10 @@ function createSubgraphNodeHandler<
|
||||
phase,
|
||||
status: 'error' as const,
|
||||
timestamp: Date.now(),
|
||||
summary: `Error: ${errorMessage}`,
|
||||
summary: `Error: ${userFacingMessage}`,
|
||||
metadata: createErrorMetadata({
|
||||
failedSubgraph: phase,
|
||||
errorMessage,
|
||||
errorMessage: rawErrorMessage,
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,10 +4,12 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { generateCodeBuilderThreadId } from '@/code-builder/utils/code-builder-session';
|
||||
import { getBuilderToolsForDisplay } from '@/tools/builder-tools';
|
||||
import type { HITLHistoryEntry, HITLInterruptValue } from '@/types/planning';
|
||||
import { isLangchainMessagesArray, LangchainMessage, Session } from '@/types/sessions';
|
||||
import { formatMessages } from '@/utils/stream-processor';
|
||||
import { generateThreadId as generateThreadIdUtil } from '@/utils/thread-id';
|
||||
|
||||
@Service()
|
||||
export class SessionManagerService {
|
||||
@@ -47,11 +49,16 @@ export class SessionManagerService {
|
||||
|
||||
/**
|
||||
* Generate a thread ID for a given workflow and user
|
||||
* @param workflowId - The workflow ID
|
||||
* @param userId - The user ID
|
||||
* @param agentType - Optional agent type to isolate sessions between different agents
|
||||
*/
|
||||
static generateThreadId(workflowId?: string, userId?: string): string {
|
||||
return workflowId
|
||||
? `workflow-${workflowId}-user-${userId ?? new Date().getTime()}`
|
||||
: crypto.randomUUID();
|
||||
static generateThreadId(
|
||||
workflowId?: string,
|
||||
userId?: string,
|
||||
agentType?: 'code-builder',
|
||||
): string {
|
||||
return generateThreadIdUtil(workflowId, userId, agentType);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,10 +243,14 @@ export class SessionManagerService {
|
||||
|
||||
/**
|
||||
* Get sessions for a given workflow and user
|
||||
* @param workflowId - The workflow ID
|
||||
* @param userId - The user ID
|
||||
* @param agentType - Optional agent type to query the correct session thread
|
||||
*/
|
||||
async getSessions(
|
||||
workflowId: string | undefined,
|
||||
userId: string | undefined,
|
||||
agentType?: 'code-builder',
|
||||
): Promise<{ sessions: Session[] }> {
|
||||
// For now, we'll return the current session if we have a workflowId
|
||||
// MemorySaver doesn't expose a way to list all threads, so we'll need to
|
||||
@@ -247,7 +258,7 @@ export class SessionManagerService {
|
||||
const sessions: Session[] = [];
|
||||
|
||||
if (workflowId) {
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, agentType);
|
||||
const threadConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
@@ -321,8 +332,9 @@ export class SessionManagerService {
|
||||
workflowId: string,
|
||||
userId: string | undefined,
|
||||
messageId: string,
|
||||
agentType?: 'code-builder',
|
||||
): Promise<boolean> {
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId, agentType);
|
||||
const threadConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: threadId,
|
||||
@@ -389,10 +401,70 @@ export class SessionManagerService {
|
||||
newCount: truncatedMessages.length,
|
||||
});
|
||||
|
||||
// Reset the code-builder agent context session when truncating code-builder messages
|
||||
if (agentType === 'code-builder' && userId) {
|
||||
await this.resetCodeBuilderSession(workflowId, userId);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger?.error('Failed to truncate messages', { threadId, messageId, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the code-builder agent context session.
|
||||
* Called during restore to clear stale agent context (userMessages + summary).
|
||||
*/
|
||||
private async resetCodeBuilderSession(workflowId: string, userId: string): Promise<void> {
|
||||
const sessionThreadId = generateCodeBuilderThreadId(workflowId, userId);
|
||||
const sessionConfig: RunnableConfig = {
|
||||
configurable: {
|
||||
thread_id: sessionThreadId,
|
||||
},
|
||||
};
|
||||
|
||||
const existingTuple = await this.checkpointer.getTuple(sessionConfig).catch(() => undefined);
|
||||
const existingCheckpoint = existingTuple?.checkpoint;
|
||||
|
||||
const checkpoint: Checkpoint = existingCheckpoint
|
||||
? {
|
||||
...existingCheckpoint,
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
...existingCheckpoint.channel_values,
|
||||
codeBuilderSession: {
|
||||
userMessages: [],
|
||||
previousSummary: undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
v: 1,
|
||||
id: crypto.randomUUID(),
|
||||
ts: new Date().toISOString(),
|
||||
channel_values: {
|
||||
codeBuilderSession: {
|
||||
userMessages: [],
|
||||
previousSummary: undefined,
|
||||
},
|
||||
},
|
||||
channel_versions: {},
|
||||
versions_seen: {},
|
||||
};
|
||||
|
||||
const sessionMetadata = existingTuple?.metadata ?? {
|
||||
source: 'update' as const,
|
||||
step: -1,
|
||||
parents: {},
|
||||
};
|
||||
|
||||
await this.checkpointer.put(sessionConfig, checkpoint, sessionMetadata);
|
||||
|
||||
this.logger?.debug('Code-builder session reset after truncation', {
|
||||
sessionThreadId,
|
||||
workflowId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+63
-8
@@ -9,7 +9,7 @@ import type { IUser, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { AiWorkflowBuilderService } from '@/ai-workflow-builder-agent.service';
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import { anthropicClaudeSonnet45 } from '@/llm-config';
|
||||
import { anthropicClaudeSonnet45, anthropicClaudeSonnet45Think } from '@/llm-config';
|
||||
import { SessionManagerService } from '@/session-manager.service';
|
||||
import { formatMessages } from '@/utils/stream-processor';
|
||||
import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent';
|
||||
@@ -40,6 +40,7 @@ jest.mock('@/workflow-builder-agent');
|
||||
jest.mock('@/session-manager.service');
|
||||
jest.mock('@/llm-config', () => ({
|
||||
anthropicClaudeSonnet45: jest.fn(),
|
||||
anthropicClaudeSonnet45Think: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/utils/stream-processor', () => ({
|
||||
formatMessages: jest.fn(),
|
||||
@@ -58,6 +59,9 @@ const MockedSessionManagerService = SessionManagerService as jest.MockedClass<
|
||||
const anthropicClaudeSonnet45Mock = anthropicClaudeSonnet45 as jest.MockedFunction<
|
||||
typeof anthropicClaudeSonnet45
|
||||
>;
|
||||
const anthropicClaudeSonnet45ThinkMock = anthropicClaudeSonnet45Think as jest.MockedFunction<
|
||||
typeof anthropicClaudeSonnet45Think
|
||||
>;
|
||||
const formatMessagesMock = formatMessages as jest.MockedFunction<typeof formatMessages>;
|
||||
|
||||
describe('AiWorkflowBuilderService', () => {
|
||||
@@ -199,6 +203,7 @@ describe('AiWorkflowBuilderService', () => {
|
||||
});
|
||||
|
||||
anthropicClaudeSonnet45Mock.mockResolvedValue(mockChatAnthropic);
|
||||
anthropicClaudeSonnet45ThinkMock.mockResolvedValue(mockChatAnthropic);
|
||||
|
||||
// Mock onCreditsUpdated callback
|
||||
mockOnCreditsUpdated = jest.fn();
|
||||
@@ -466,7 +471,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
const result = await service.getSessions(undefined, mockUser);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(undefined, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return session when workflowId exists', async () => {
|
||||
@@ -493,7 +502,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
lastUpdated: '2023-12-01T12:00:00Z',
|
||||
});
|
||||
expect(result.sessions[0].messages).toHaveLength(2);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing checkpoint gracefully', async () => {
|
||||
@@ -505,7 +518,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
const result = await service.getSessions(workflowId, mockUser);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle checkpoint without messages', async () => {
|
||||
@@ -525,7 +542,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle checkpoint with null messages', async () => {
|
||||
@@ -545,7 +566,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].messages).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work without user', async () => {
|
||||
@@ -557,7 +582,33 @@ describe('AiWorkflowBuilderService', () => {
|
||||
const result = await service.getSessions(workflowId);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, undefined);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, undefined, undefined);
|
||||
});
|
||||
|
||||
it('should pass codeBuilder flag to sessionManager', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
|
||||
|
||||
await service.getSessions(workflowId, mockUser, true);
|
||||
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
'code-builder',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass code-builder agentType when codeBuilder is false', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
(mockSessionManager.getSessions as jest.Mock).mockResolvedValue({ sessions: [] });
|
||||
|
||||
await service.getSessions(workflowId, mockUser, false);
|
||||
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -601,7 +652,11 @@ describe('AiWorkflowBuilderService', () => {
|
||||
expect(sessions.sessions[0].sessionId).toBe(
|
||||
'workflow-integration-test-workflow-user-test-user-id',
|
||||
);
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(workflowId, 'test-user-id');
|
||||
expect(mockSessionManager.getSessions).toHaveBeenCalledWith(
|
||||
workflowId,
|
||||
'test-user-id',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,21 @@ describe('SessionManagerService', () => {
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should append -code suffix when agentType is code-builder', () => {
|
||||
const threadId = SessionManagerService.generateThreadId(
|
||||
'workflow-123',
|
||||
'user-456',
|
||||
'code-builder',
|
||||
);
|
||||
expect(threadId).toBe('workflow-workflow-123-user-user-456-code');
|
||||
});
|
||||
|
||||
it('should not append suffix when agentType is undefined', () => {
|
||||
const threadId = SessionManagerService.generateThreadId('workflow-123', 'user-456');
|
||||
expect(threadId).toBe('workflow-workflow-123-user-user-456');
|
||||
expect(threadId).not.toContain('-code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckpointer', () => {
|
||||
@@ -326,6 +341,54 @@ describe('SessionManagerService', () => {
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
expect(mockMemorySaver.getTuple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should query code-builder thread when agentType is code-builder', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Hello'), new AIMessage('Hi there!')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
const result = await service.getSessions(workflowId, userId, 'code-builder');
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].sessionId).toBe('workflow-test-workflow-user-test-user-code');
|
||||
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
|
||||
configurable: {
|
||||
thread_id: 'workflow-test-workflow-user-test-user-code',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should query default thread when agentType is not provided', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [new HumanMessage('Hello')],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
|
||||
await service.getSessions(workflowId, userId);
|
||||
|
||||
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
|
||||
configurable: {
|
||||
thread_id: 'workflow-test-workflow-user-test-user',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with other components', () => {
|
||||
@@ -686,6 +749,178 @@ describe('SessionManagerService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use code-builder thread ID when agentType is code-builder', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messageId = 'msg-2';
|
||||
|
||||
const msg1 = new HumanMessage({ content: 'Hello' });
|
||||
msg1.additional_kwargs = { messageId: 'msg-1' };
|
||||
|
||||
const msg2 = new AIMessage({ content: 'Hi there' });
|
||||
msg2.additional_kwargs = { messageId: 'msg-2' };
|
||||
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [msg1, msg2],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
(mockMemorySaver.put as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.truncateMessagesAfter(
|
||||
workflowId,
|
||||
userId,
|
||||
messageId,
|
||||
'code-builder',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
|
||||
configurable: {
|
||||
thread_id: 'workflow-test-workflow-user-test-user-code',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not use code-builder thread ID when agentType is undefined', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messageId = 'msg-1';
|
||||
|
||||
const msg1 = new HumanMessage({ content: 'Hello' });
|
||||
msg1.additional_kwargs = { messageId: 'msg-1' };
|
||||
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [msg1],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
(mockMemorySaver.put as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.truncateMessagesAfter(workflowId, userId, messageId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockMemorySaver.getTuple).toHaveBeenCalledWith({
|
||||
configurable: {
|
||||
thread_id: 'workflow-test-workflow-user-test-user',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset codeBuilderSession when agentType is code-builder', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messageId = 'msg-2';
|
||||
|
||||
const msg1 = new HumanMessage({ content: 'Hello' });
|
||||
msg1.additional_kwargs = { messageId: 'msg-1' };
|
||||
|
||||
const msg2 = new AIMessage({ content: 'Hi there' });
|
||||
msg2.additional_kwargs = { messageId: 'msg-2' };
|
||||
|
||||
// Mock for the messages thread (first getTuple call)
|
||||
const messagesCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [msg1, msg2],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock for the code-builder session thread (second getTuple call)
|
||||
const sessionCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
codeBuilderSession: {
|
||||
userMessages: ['old message'],
|
||||
previousSummary: 'old summary',
|
||||
},
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
metadata: {
|
||||
source: 'update' as const,
|
||||
step: -1,
|
||||
parents: {},
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock)
|
||||
.mockResolvedValueOnce(messagesCheckpoint)
|
||||
.mockResolvedValueOnce(sessionCheckpoint);
|
||||
(mockMemorySaver.put as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.truncateMessagesAfter(
|
||||
workflowId,
|
||||
userId,
|
||||
messageId,
|
||||
'code-builder',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// First put: truncated messages
|
||||
// Second put: reset codeBuilderSession
|
||||
expect(mockMemorySaver.put).toHaveBeenCalledTimes(2);
|
||||
expect(mockMemorySaver.put).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
configurable: {
|
||||
thread_id: 'code-builder-test-workflow-test-user',
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
channel_values: expect.objectContaining({
|
||||
codeBuilderSession: {
|
||||
userMessages: [],
|
||||
previousSummary: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
sessionCheckpoint.metadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not reset codeBuilderSession when agentType is undefined', async () => {
|
||||
const workflowId = 'test-workflow';
|
||||
const userId = 'test-user';
|
||||
const messageId = 'msg-2';
|
||||
|
||||
const msg1 = new HumanMessage({ content: 'Hello' });
|
||||
msg1.additional_kwargs = { messageId: 'msg-1' };
|
||||
|
||||
const msg2 = new AIMessage({ content: 'Hi there' });
|
||||
msg2.additional_kwargs = { messageId: 'msg-2' };
|
||||
|
||||
const mockCheckpoint = {
|
||||
checkpoint: {
|
||||
channel_values: {
|
||||
messages: [msg1, msg2],
|
||||
},
|
||||
ts: '2023-12-01T12:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockMemorySaver.getTuple as jest.Mock).mockResolvedValue(mockCheckpoint);
|
||||
(mockMemorySaver.put as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.truncateMessagesAfter(workflowId, userId, messageId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Only one put call for the messages truncation
|
||||
expect(mockMemorySaver.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HITL history and session replay', () => {
|
||||
|
||||
@@ -36,6 +36,13 @@ jest.mock('@/utils/stream-processor', () => ({
|
||||
formatMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockCodeWorkflowBuilderChat = jest.fn();
|
||||
jest.mock('@/code-builder', () => ({
|
||||
CodeWorkflowBuilder: jest.fn().mockImplementation(() => ({
|
||||
chat: mockCodeWorkflowBuilderChat,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockRandomUUID = jest.fn();
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
@@ -44,8 +51,10 @@ Object.defineProperty(global, 'crypto', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
import { CodeWorkflowBuilder } from '@/code-builder';
|
||||
import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||
import { ValidationError } from '@/errors';
|
||||
import type { PlanInterruptValue, PlanOutput } from '@/types/planning';
|
||||
import type { StreamOutput } from '@/types/streaming';
|
||||
import { createStreamProcessor } from '@/utils/stream-processor';
|
||||
import {
|
||||
@@ -274,4 +283,161 @@ describe('WorkflowBuilderAgent', () => {
|
||||
}).rejects.toThrow(unknownError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybrid plan+codeBuilder routing', () => {
|
||||
const MockedCodeWorkflowBuilder = CodeWorkflowBuilder as jest.MockedClass<
|
||||
typeof CodeWorkflowBuilder
|
||||
>;
|
||||
|
||||
const mockPlan: PlanOutput = {
|
||||
summary: 'Fetch weather and send Slack alert',
|
||||
trigger: 'Runs every morning at 7 AM',
|
||||
steps: [
|
||||
{ description: 'Fetch weather forecast', suggestedNodes: ['n8n-nodes-base.httpRequest'] },
|
||||
{ description: 'Check if rain is predicted' },
|
||||
{ description: 'Send Slack notification', suggestedNodes: ['n8n-nodes-base.slack'] },
|
||||
],
|
||||
};
|
||||
|
||||
const mockPlanInterrupt: PlanInterruptValue = {
|
||||
type: 'plan',
|
||||
plan: mockPlan,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MockedCodeWorkflowBuilder.mockClear();
|
||||
mockCodeWorkflowBuilderChat.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Built workflow' }],
|
||||
} as StreamOutput;
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should route to multi-agent for initial plan request when codeBuilder+planMode enabled', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: '123',
|
||||
message: 'Create a weather alert workflow',
|
||||
featureFlags: { codeBuilder: true, planMode: true },
|
||||
mode: 'plan',
|
||||
};
|
||||
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Planning...' }],
|
||||
};
|
||||
mockCreateStreamProcessor.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockStreamOutput;
|
||||
})(),
|
||||
);
|
||||
|
||||
const generator = agent.chat(payload);
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual(mockStreamOutput);
|
||||
// Multi-agent path uses createStreamProcessor; code builder does not
|
||||
expect(mockCreateStreamProcessor).toHaveBeenCalled();
|
||||
expect(MockedCodeWorkflowBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should route to CodeWorkflowBuilder with plan on plan approval', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: '123',
|
||||
message: 'Create a weather alert workflow',
|
||||
featureFlags: { codeBuilder: true, planMode: true },
|
||||
resumeData: { action: 'approve' },
|
||||
resumeInterrupt: mockPlanInterrupt,
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload);
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
// Should have constructed CodeWorkflowBuilder
|
||||
expect(MockedCodeWorkflowBuilder).toHaveBeenCalled();
|
||||
// Should have passed the plan via the payload
|
||||
expect(mockCodeWorkflowBuilderChat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ planOutput: mockPlan }),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
);
|
||||
// Should NOT have used multi-agent stream processor
|
||||
expect(mockCreateStreamProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should route to multi-agent on plan modification', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: '123',
|
||||
message: 'Create a weather alert workflow',
|
||||
featureFlags: { codeBuilder: true, planMode: true },
|
||||
resumeData: { action: 'modify', feedback: 'Add error handling' },
|
||||
resumeInterrupt: mockPlanInterrupt,
|
||||
};
|
||||
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'Re-planning...' }],
|
||||
};
|
||||
mockCreateStreamProcessor.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockStreamOutput;
|
||||
})(),
|
||||
);
|
||||
|
||||
const generator = agent.chat(payload);
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual(mockStreamOutput);
|
||||
expect(mockCreateStreamProcessor).toHaveBeenCalled();
|
||||
expect(MockedCodeWorkflowBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should route to multi-agent on plan rejection', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: '123',
|
||||
message: 'Create a weather alert workflow',
|
||||
featureFlags: { codeBuilder: true, planMode: true },
|
||||
resumeData: { action: 'reject' },
|
||||
resumeInterrupt: mockPlanInterrupt,
|
||||
};
|
||||
|
||||
const mockStreamOutput: StreamOutput = {
|
||||
messages: [{ role: 'assistant', type: 'message', text: 'OK, cancelled.' }],
|
||||
};
|
||||
mockCreateStreamProcessor.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockStreamOutput;
|
||||
})(),
|
||||
);
|
||||
|
||||
const generator = agent.chat(payload);
|
||||
const result = await generator.next();
|
||||
|
||||
expect(result.value).toEqual(mockStreamOutput);
|
||||
expect(mockCreateStreamProcessor).toHaveBeenCalled();
|
||||
expect(MockedCodeWorkflowBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should route to CodeWorkflowBuilder without plan when codeBuilder enabled but no planMode', async () => {
|
||||
const payload: ChatPayload = {
|
||||
id: '123',
|
||||
message: 'Create a simple workflow',
|
||||
featureFlags: { codeBuilder: true },
|
||||
};
|
||||
|
||||
const generator = agent.chat(payload);
|
||||
for await (const _ of generator) {
|
||||
// consume
|
||||
}
|
||||
|
||||
expect(MockedCodeWorkflowBuilder).toHaveBeenCalled();
|
||||
expect(mockCodeWorkflowBuilderChat).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ planOutput: expect.anything() }),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
CODE_BUILDER_GET_NODE_TYPES_TOOL,
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL,
|
||||
CODE_BUILDER_SEARCH_NODES_TOOL,
|
||||
CODE_BUILDER_TEXT_EDITOR_TOOL,
|
||||
CODE_BUILDER_VALIDATE_TOOL,
|
||||
} from '@/code-builder/constants';
|
||||
import type { BuilderToolBase } from '@/utils/stream-processor';
|
||||
import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
|
||||
@@ -61,6 +68,12 @@ export function getBuilderToolsForDisplay({
|
||||
// Workflow context tools
|
||||
GET_WORKFLOW_OVERVIEW_TOOL,
|
||||
GET_NODE_CONTEXT_TOOL,
|
||||
// CodeBuilderAgent tools
|
||||
CODE_BUILDER_TEXT_EDITOR_TOOL,
|
||||
CODE_BUILDER_VALIDATE_TOOL,
|
||||
CODE_BUILDER_SEARCH_NODES_TOOL,
|
||||
CODE_BUILDER_GET_NODE_TYPES_TOOL,
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL,
|
||||
);
|
||||
|
||||
return tools;
|
||||
|
||||
@@ -130,6 +130,29 @@ jest.mock('../get-node-context.tool', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/code-builder/constants', () => ({
|
||||
CODE_BUILDER_TEXT_EDITOR_TOOL: {
|
||||
toolName: 'str_replace_based_edit_tool',
|
||||
displayTitle: 'Editing workflow',
|
||||
},
|
||||
CODE_BUILDER_VALIDATE_TOOL: {
|
||||
toolName: 'validate_workflow',
|
||||
displayTitle: 'Validating workflow',
|
||||
},
|
||||
CODE_BUILDER_SEARCH_NODES_TOOL: {
|
||||
toolName: 'search_nodes',
|
||||
displayTitle: 'Searching nodes',
|
||||
},
|
||||
CODE_BUILDER_GET_NODE_TYPES_TOOL: {
|
||||
toolName: 'get_node_types',
|
||||
displayTitle: 'Getting node definitions',
|
||||
},
|
||||
CODE_BUILDER_GET_SUGGESTED_NODES_TOOL: {
|
||||
toolName: 'get_suggested_nodes',
|
||||
displayTitle: 'Getting suggested nodes',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('builder-tools', () => {
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
|
||||
@@ -145,12 +168,14 @@ describe('builder-tools', () => {
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
// 18 tools: best_practices, workflow_examples, node_search, node_details, add_node,
|
||||
// 23 tools: best_practices, workflow_examples, node_search, node_details, add_node,
|
||||
// connect_nodes, remove_connection, remove_node, rename_node, update_node_parameters,
|
||||
// get_node_parameter, validate_structure, validate_configuration,
|
||||
// get_execution_schema, get_execution_logs, get_expression_data_mapping,
|
||||
// get_workflow_overview, get_node_context
|
||||
expect(tools).toHaveLength(18);
|
||||
// get_workflow_overview, get_node_context,
|
||||
// + 5 CodeBuilderAgent tools: str_replace_based_edit_tool, validate_workflow,
|
||||
// search_nodes, get_node_types, get_suggested_nodes
|
||||
expect(tools).toHaveLength(23);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
});
|
||||
|
||||
@@ -160,7 +185,7 @@ describe('builder-tools', () => {
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(17);
|
||||
expect(tools).toHaveLength(22);
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when feature flag is not provided', () => {
|
||||
@@ -168,7 +193,7 @@ describe('builder-tools', () => {
|
||||
nodeTypes: parsedNodeTypes,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(17);
|
||||
expect(tools).toHaveLength(22);
|
||||
});
|
||||
|
||||
it('should work with empty node types array', () => {
|
||||
@@ -176,7 +201,7 @@ describe('builder-tools', () => {
|
||||
nodeTypes: [],
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(17);
|
||||
expect(tools).toHaveLength(22);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ export interface ToolProgressChunk {
|
||||
export interface WorkflowUpdateChunk {
|
||||
role: 'assistant';
|
||||
type: 'workflow-updated';
|
||||
/** JSON-stringified workflow */
|
||||
codeSnippet: string;
|
||||
/** Number of agentic loop iterations required */
|
||||
iterationCount?: number;
|
||||
/** Source code that generated the workflow (only populated during evaluations) */
|
||||
sourceCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +55,16 @@ export interface PlanChunk {
|
||||
plan: PlanOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session messages chunk for persistence
|
||||
* Contains the full message history for saving to session storage
|
||||
*/
|
||||
export interface SessionMessagesChunk {
|
||||
type: 'session-messages';
|
||||
/** Raw LangChain messages for session persistence */
|
||||
messages: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all stream chunks
|
||||
*/
|
||||
@@ -58,6 +73,7 @@ export type StreamChunk =
|
||||
| ToolProgressChunk
|
||||
| WorkflowUpdateChunk
|
||||
| ExecutionRequestChunk
|
||||
| SessionMessagesChunk
|
||||
| QuestionsChunk
|
||||
| PlanChunk;
|
||||
|
||||
|
||||
+69
@@ -563,5 +563,74 @@ describe('Cache Control Helpers', () => {
|
||||
}>;
|
||||
expect(lastContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should not add cache_control to ToolMessage with empty string content', () => {
|
||||
const messages = [new ToolMessage({ content: '', tool_call_id: '1' })];
|
||||
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
// Empty string content should remain unchanged — no cache_control added
|
||||
expect(messages[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('should not add cache_control to empty content when non-empty messages precede it', () => {
|
||||
const messages = [
|
||||
new HumanMessage('user request'),
|
||||
new AIMessage('response'),
|
||||
new ToolMessage({ content: '', tool_call_id: '1' }),
|
||||
];
|
||||
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
// First message should stay as string (not the last user/tool)
|
||||
expect(typeof messages[0].content).toBe('string');
|
||||
// Empty tool message should remain unchanged
|
||||
expect(messages[2].content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCacheControlMarkers - empty content handling', () => {
|
||||
it('should not add cache_control to last message with empty string content', () => {
|
||||
const messages = [new ToolMessage({ content: '', tool_call_id: '1' })];
|
||||
|
||||
applyCacheControlMarkers(messages, [0], '');
|
||||
|
||||
// Empty content should remain unchanged
|
||||
expect(messages[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('should not add cache_control to second-to-last message with empty string content', () => {
|
||||
const messages = [
|
||||
new ToolMessage({ content: '', tool_call_id: '1' }),
|
||||
new HumanMessage('user message'),
|
||||
];
|
||||
|
||||
applyCacheControlMarkers(messages, [0, 1], '\n<workflow/>');
|
||||
|
||||
// Empty tool message should stay as empty string
|
||||
expect(messages[0].content).toBe('');
|
||||
|
||||
// Non-empty last message should still get the marker
|
||||
const content1 = messages[1].content as Array<{
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(content1[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should not add cache_control to array content block with empty text', () => {
|
||||
const toolMessage = new ToolMessage({ content: '', tool_call_id: '1' });
|
||||
toolMessage.content = [{ type: 'text' as const, text: '' }];
|
||||
|
||||
const messages = [toolMessage];
|
||||
|
||||
applyCacheControlMarkers(messages, [0], '');
|
||||
|
||||
// Empty text block should NOT get cache_control
|
||||
const content = messages[0].content as Array<{
|
||||
text: string;
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(content[0].cache_control).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ function isTextBlock(
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'text' in block &&
|
||||
typeof (block as { text: unknown }).text === 'string'
|
||||
typeof (block as { text: unknown }).text === 'string' &&
|
||||
(block as { text: string }).text.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,13 +127,15 @@ export function applyCacheControlMarkers(
|
||||
const secondToLastMessage = messages[secondToLastIdx];
|
||||
|
||||
if (typeof secondToLastMessage.content === 'string') {
|
||||
secondToLastMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: secondToLastMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
if (secondToLastMessage.content.length > 0) {
|
||||
secondToLastMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: secondToLastMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
}
|
||||
} else if (Array.isArray(secondToLastMessage.content)) {
|
||||
const lastBlock = secondToLastMessage.content[secondToLastMessage.content.length - 1];
|
||||
if (isTextBlock(lastBlock)) {
|
||||
@@ -146,13 +149,15 @@ export function applyCacheControlMarkers(
|
||||
const lastUserToolMessage = messages[lastUserToolIdx];
|
||||
|
||||
if (typeof lastUserToolMessage.content === 'string') {
|
||||
lastUserToolMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: lastUserToolMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
if (lastUserToolMessage.content.length > 0) {
|
||||
lastUserToolMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: lastUserToolMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
}
|
||||
} else if (Array.isArray(lastUserToolMessage.content)) {
|
||||
const lastBlock = lastUserToolMessage.content[lastUserToolMessage.content.length - 1];
|
||||
if (isTextBlock(lastBlock)) {
|
||||
@@ -196,13 +201,15 @@ export function applySubgraphCacheMarkers(messages: BaseMessage[]): void {
|
||||
const lastMessage = messages[lastIdx];
|
||||
|
||||
if (typeof lastMessage.content === 'string') {
|
||||
lastMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: lastMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
if (lastMessage.content.length > 0) {
|
||||
lastMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: lastMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
}
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
|
||||
if (isTextBlock(lastBlock)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user