{"record":{"id":"772c990600ad6867","repo":"n8n-io/n8n","slug":"expectedtoolinvocations-must-specify-a-non-empty","errorCode":null,"errorMessage":"expectedToolInvocations must specify a non-empty `anyOf`, `noneOf`, `anyOfToolCalls`, `allOfToolCalls`, or `noneOfToolCalls` list","messagePattern":"expectedToolInvocations must specify a non-empty `anyOf`, `noneOf`, `anyOfToolCalls`, `allOfToolCalls`, or `noneOfToolCalls` list","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/@n8n/instance-ai/evaluations/discovery/expected-tools-invoked.ts","lineNumber":63,"sourceCode":"\t\t.filter((a) => a.role.length > 0)\n\t\t.map((a) => `${SPAWN_PREFIX}${a.role}`);\n}\n\nfunction matches(name: string, invokedTools: string[], spawnedAgents: string[]): boolean {\n\tif (name.startsWith(SPAWN_PREFIX)) {\n\t\treturn spawnedAgents.includes(name);\n\t}\n\treturn invokedTools.includes(name);\n}\n\nfunction validateRule(rule: ExpectedToolInvocations): void {\n\tconst hasAnyOf = Array.isArray(rule.anyOf) && rule.anyOf.length > 0;\n\tconst hasNoneOf = Array.isArray(rule.noneOf) && rule.noneOf.length > 0;\n\tconst hasAnyOfToolCalls = Array.isArray(rule.anyOfToolCalls) && rule.anyOfToolCalls.length > 0;\n\tconst hasAllOfToolCalls = Array.isArray(rule.allOfToolCalls) && rule.allOfToolCalls.length > 0;\n\tconst hasNoneOfToolCalls = Array.isArray(rule.noneOfToolCalls) && rule.noneOfToolCalls.length > 0;\n\tif (!hasAnyOf && !hasNoneOf && !hasAnyOfToolCalls && !hasAllOfToolCalls && !hasNoneOfToolCalls) {\n\t\tthrow new Error(\n\t\t\t'expectedToolInvocations must specify a non-empty `anyOf`, `noneOf`, `anyOfToolCalls`, `allOfToolCalls`, or `noneOfToolCalls` list',\n\t\t);\n\t}\n}\n\nfunction toolCallMatchesExpectation(\n\ttoolCall: EventOutcome['toolCalls'][number],\n\texpectation: ForbiddenToolCall,\n): boolean {\n\tif (toolCall.toolName !== expectation.toolName) return false;\n\n\tconst argsContainAny = expectation.argsContainAny ?? [];\n\tif (argsContainAny.length === 0) return true;\n\n\tconst argsText = JSON.stringify(toolCall.args).toLowerCase();\n\treturn argsContainAny.some((term) => argsText.includes(term.toLowerCase()));\n}\n","sourceCodeStart":45,"sourceCodeEnd":81,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/instance-ai/evaluations/discovery/expected-tools-invoked.ts#L45-L81","documentation":"Thrown by `validateRule()` in discovery/expected-tools-invoked.ts when an `ExpectedToolInvocations` rule has none of the five list fields populated with at least one entry. The validator runs at check-execution time (inside `runExpectedToolsInvokedCheck`) as a defense-in-depth: the case-file zod schema already enforces this via `.refine`, so reaching this throw means the rule was constructed in code (not loaded from JSON) or the schema was bypassed. Without an expectation, the check would pass vacuously, masking a real regression — hence the explicit fail.","triggerScenarios":"A test constructs an `ExpectedToolInvocations` object programmatically with all lists undefined/empty. A migration/refactor produced a rule object missing its keys. A future code path calls `runExpectedToolsInvokedCheck` with a hand-built rule and forgets to populate any list. The schema `.refine` was weakened or removed, letting an empty rule through.","commonSituations":"Unit tests building minimal fixtures. A helper that builds expectations conditionally and all conditions were false. Drift between the runtime validator and the zod schema after a refactor.","solutions":["Ensure the rule object passed to `runExpectedToolsInvokedCheck` has at least one of `anyOf`, `noneOf`, `anyOfToolCalls`, `allOfToolCalls`, or `noneOfToolCalls`, each as a non-empty array.","If the rule came from a JSON case file, fix the file so `expectedToolInvocations` declares at least one populated list (the zod schema should have caught this — investigate why it didn't).","If writing a test helper, default to populating `anyOf` with the tool you're asserting was invoked."],"exampleFix":"// before\nconst rule = { anyOf: [], noneOf: [] };\nrunExpectedToolsInvokedCheck({ ...scenario, expectedToolInvocations: rule }, outcome);\n// after\nconst rule = { anyOf: ['create_credential'] };\nrunExpectedToolsInvokedCheck({ ...scenario, expectedToolInvocations: rule }, outcome);","handlingStrategy":"type-guard","validationCode":"function hasNonEmptyExpectation(rule: ExpectedToolInvocations): boolean {\n  return (\n    (Array.isArray(rule.anyOf) && rule.anyOf.length > 0) ||\n    (Array.isArray(rule.noneOf) && rule.noneOf.length > 0) ||\n    (Array.isArray(rule.anyOfToolCalls) && rule.anyOfToolCalls.length > 0) ||\n    (Array.isArray(rule.allOfToolCalls) && rule.allOfToolCalls.length > 0) ||\n    (Array.isArray(rule.noneOfToolCalls) && rule.noneOfToolCalls.length > 0)\n  );\n}\nif (!hasNonEmptyExpectation(rule)) throw new Error('rule has no expectation');","typeGuard":"function isValidExpectationRule(rule: unknown): rule is ExpectedToolInvocations {\n  if (typeof rule !== 'object' || rule === null) return false;\n  const r = rule as Record<string, unknown>;\n  return ['anyOf','noneOf','anyOfToolCalls','allOfToolCalls','noneOfToolCalls']\n    .some((k) => Array.isArray(r[k]) && (r[k] as unknown[]).length > 0);\n}","tryCatchPattern":null,"preventionTips":["Always go through the zod schema for JSON-sourced rules so the `.refine` catches empty expectations at load time.","In test helpers, default to populating at least one list rather than building an all-empty fixture.","If you weaken the schema, mirror the runtime validator so they can't drift."],"tags":["discovery","validation","test-config","defensive-check","developer-error"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}