feat(agents): Show workflow-triggered runs in agent session history
Some checks failed
CI: Python / Checks (push) Has been cancelled

Makes the MessageAnAgent node's runs first-class agent sessions: they now
appear in the agent's session list alongside chat/Slack/schedule sources,
can continue an existing thread (reusing memory) across executions, and
can be opened from the execution log.

- Record workflow-triggered runs through ExecutionRecorder and persist
  them via recordMessage with a new AGENT_WORKFLOW_TRIGGER_TYPE source,
  so workflow-invoked agent runs are visible in the session list.
- Add Advanced > Session ID option on MessageAnAgent (wired through
  ExecuteAgentInfo.sessionId and consumed in BaseExecuteContext) to
  allow continuing the same thread across executions.
- Emit session identifiers in the node output and add a "View session"
  link in LogDetailsPanel via the new useMessageAgentSessionLink
  composable; opens in a new tab so the popped-out logs panel works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Drury 2026-05-06 19:58:57 +01:00
parent 31f577a39f
commit e58504a20b
11 changed files with 575 additions and 87 deletions

View File

@ -48,6 +48,13 @@ export const INCOMPATIBLE_WORKFLOW_TOOL_BODY_NODE_TYPES = [
export const AGENT_SCHEDULE_TRIGGER_TYPE = 'schedule';
/**
* Source string recorded on agent executions invoked from a workflow via the
* MessageAnAgent node. Mirrors the pattern set by chat/slack/schedule sources
* so the session detail view can attribute thread origin uniformly.
*/
export const AGENT_WORKFLOW_TRIGGER_TYPE = 'workflow';
export const DEFAULT_AGENT_SCHEDULE_WAKE_UP_PROMPT =
'Automated message: you were triggered on schedule.';

View File

@ -2,12 +2,12 @@ import type {
BuiltAgent,
BuiltTool,
CredentialProvider,
GenerateResult,
StreamChunk,
ToolDescriptor,
} from '@n8n/agents';
import {
AGENT_SCHEDULE_TRIGGER_TYPE,
AGENT_WORKFLOW_TRIGGER_TYPE,
isAgentCredentialIntegration,
isAgentScheduleIntegration,
type AgentSkill,
@ -1025,8 +1025,15 @@ export class AgentsService {
/**
* Execute an SDK agent within a workflow execution context.
* Compiles a fresh isolated agent per call for credential isolation
* (does not use or affect the shared runtime cache).
*
* Streams the run rather than calling `.generate()` so the same
* `ExecutionRecorder` used by chat/Slack/schedule paths can collect a full
* `MessageRecord` (timeline, tool calls, usage). Without this, sessions
* triggered from a workflow node never appear in the agent's session list
* because nothing creates the agent execution thread row.
*
* Compiles a fresh isolated agent per call for credential isolation (does
* not use or affect the shared runtime cache).
*/
async executeForWorkflow(
agentId: string,
@ -1057,77 +1064,104 @@ export class AgentsService {
throw new OperationalError(`Failed to compile agent: ${compiled.error ?? 'unknown error'}`);
}
const result = await compiled.agent.generate(message, {
persistence: {
resourceId: executionId,
threadId,
},
const agentInstance = compiled.agent;
const recorder = new ExecutionRecorder();
// `structuredOutput` and `toolCalls` aren't surfaced by the recorder —
// pull them off the `finish` chunk and the discrete `tool-result` chunks
// directly so the workflow node receives the same shape as before.
let structuredOutput: unknown | null = null;
const toolCalls: ExecuteAgentData['toolCalls'] = [];
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
const resultStream = await agentInstance.stream(message, {
persistence: { resourceId: executionId, threadId },
});
// Check for errors
if (result.error) {
const errorMessage =
result.error instanceof Error
? result.error.message
: typeof result.error === 'string'
? result.error
: JSON.stringify(result.error);
throw new OperationalError(`Agent execution failed: ${errorMessage}`);
const reader = resultStream.stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
recorder.record(value);
if (value.type === 'tool-call') {
toolInputs.set(value.toolCallId, { toolName: value.toolName, input: value.input });
} else if (value.type === 'tool-result') {
const pending = toolInputs.get(value.toolCallId);
toolCalls.push({
toolName: value.toolName,
input: pending?.input ?? null,
result: value.output,
});
toolInputs.delete(value.toolCallId);
} else if (value.type === 'finish' && value.structuredOutput !== undefined) {
structuredOutput = value.structuredOutput;
}
}
} finally {
reader.releaseLock();
}
if (result.finishReason === 'error') {
throw new OperationalError('Agent execution finished with an error.');
}
const messageRecord = recorder.getMessageRecord();
if (result.pendingSuspend && result.pendingSuspend.length > 0) {
const toolNames = result.pendingSuspend
.map((s: { toolName: string }) => s.toolName)
.join(', ');
// Persist the thread + execution row + metadata so the session is
// listed under the agent (mirrors chat/slack/schedule recording).
// Fire-and-forget with .catch so a recording failure doesn't fail the
// workflow node — the response is already in hand.
void this.agentExecutionService
.recordMessage({
threadId,
agentId,
agentName: agentInstance.name,
projectId,
userMessage: message,
record: messageRecord,
source: AGENT_WORKFLOW_TRIGGER_TYPE,
})
.catch((error) => {
this.logger.warn('Failed to record agent execution from workflow', {
agentId,
threadId,
error: error instanceof Error ? error.message : String(error),
});
});
if (recorder.suspended) {
throw new OperationalError(
`Agent execution suspended waiting for tool approval: ${toolNames}. ` +
'Agent execution suspended waiting for tool approval. ' +
'Suspend/resume is not supported in workflow execution context.',
);
}
if (messageRecord.error) {
throw new OperationalError(`Agent execution failed: ${messageRecord.error}`);
}
if (messageRecord.finishReason === 'error') {
throw new OperationalError('Agent execution finished with an error.');
}
return {
response: this.extractTextResponse(result),
structuredOutput: result.structuredOutput ?? null,
usage: result.usage
response: messageRecord.assistantResponse,
structuredOutput: structuredOutput ?? null,
usage: messageRecord.usage
? {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
promptTokens: messageRecord.usage.promptTokens,
completionTokens: messageRecord.usage.completionTokens,
totalTokens: messageRecord.usage.totalTokens,
}
: null,
toolCalls: (result.toolCalls ?? []).map(
(tc: { tool: string; input: unknown; output: unknown }) => ({
toolName: tc.tool,
input: tc.input,
result: tc.output,
}),
),
finishReason: result.finishReason ?? 'stop',
toolCalls,
finishReason: messageRecord.finishReason,
session: {
agentId,
projectId,
sessionId: threadId,
},
};
}
/**
* Extract the text response from the last assistant message in a GenerateResult.
*/
private extractTextResponse(result: GenerateResult): string {
for (let i = result.messages.length - 1; i >= 0; i--) {
const msg = result.messages[i];
if (msg.type !== 'custom' && msg.role === 'assistant' && Array.isArray(msg.content)) {
const textParts = (msg.content as Array<{ type: string; text?: string }>)
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
.map((c) => c.text);
if (textParts.length > 0) {
return textParts.join('');
}
}
}
return '';
}
/**
* Get the JSON config for an agent.
*/

View File

@ -166,7 +166,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
throw new OperationalError('Agent execution is not available in this context');
}
const threadId = `${executionId}-${itemIndex}`;
const threadId = agentInfo.sessionId?.trim() || `${executionId}-${itemIndex}`;
return await this.additionalData.executeAgent(
agentInfo.agentId,

View File

@ -1665,6 +1665,7 @@
"logs.overview.body.toggleRow": "Toggle row",
"logs.details.header.actions.input": "Input",
"logs.details.header.actions.output": "Output",
"logs.details.header.actions.viewAgentSession": "View session",
"logs.details.body.itemCount": "{count} item | {count} items",
"logs.details.body.multipleInputs": "Multiple inputs. View them by {button}",
"logs.details.body.multipleInputs.openingTheNode": "opening the node",

View File

@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import { computed, defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { createRouter, createWebHistory } from 'vue-router';
import type { ITaskData } from 'n8n-workflow';
import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes';
import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants';
import type { LogEntry } from '@/features/execution/logs/logs.types';
import { useMessageAgentSessionLink } from '../composables/useMessageAgentSessionLink';
function makeLogEntry(overrides: Partial<LogEntry> = {}): LogEntry {
// Only the fields the composable reads matter; the rest is cast through to
// keep this fixture small and avoid pulling in a real Workflow factory.
const base = {
id: 'log-1',
runIndex: 0,
children: [],
consumedTokens: {
completionTokens: 0,
isEstimate: false,
promptTokens: 0,
totalTokens: 0,
},
executionId: 'exec-1',
execution: { resultData: { runData: {} } },
isSubExecution: false,
node: {
id: 'node-1',
name: 'Message an Agent',
type: MESSAGE_AN_AGENT_NODE_TYPE,
typeVersion: 1,
parameters: {},
position: [0, 0],
},
runData: undefined,
workflow: {},
};
return { ...base, ...overrides } as unknown as LogEntry;
}
function runWithRouter(
logEntry: { value: LogEntry | undefined },
registerSessionRoute: boolean,
): { link: () => ReturnType<typeof useMessageAgentSessionLink>['link']['value'] } {
const router = createRouter({
history: createWebHistory(),
routes: registerSessionRoute
? [
{
name: AGENT_SESSION_DETAIL_VIEW,
path: '/projects/:projectId/agents/:agentId/sessions/:threadId',
component: () => h('div'),
},
]
: [{ path: '/', component: () => h('div') }],
});
let captured: ReturnType<typeof useMessageAgentSessionLink>['link'] | null = null;
const Harness = defineComponent({
setup() {
captured = useMessageAgentSessionLink(computed(() => logEntry.value)).link;
return () => h('div');
},
});
mount(Harness, { global: { plugins: [router] } });
return { link: () => captured!.value };
}
const sessionRunData = {
executionStatus: 'success',
startTime: 0,
executionTime: 1,
source: [],
data: {
main: [
[
{
json: {
response: 'hi',
session: {
agentId: 'agent-1',
projectId: 'project-1',
sessionId: 'thread-1',
},
},
},
],
],
},
} as unknown as ITaskData;
describe('useMessageAgentSessionLink', () => {
it('returns a resolved href + open() for a messageAnAgent run with a session block', () => {
const logEntry = { value: makeLogEntry({ runData: sessionRunData }) };
const { link } = runWithRouter(logEntry, true);
const value = link();
expect(value).not.toBeNull();
expect(value!.href).toBe('/projects/project-1/agents/agent-1/sessions/thread-1');
expect(typeof value!.open).toBe('function');
});
it('returns null when the node-type is not messageAnAgent', () => {
const logEntry = {
value: makeLogEntry({
node: {
id: 'n',
name: 'Other',
type: 'n8n-nodes-base.set',
typeVersion: 1,
parameters: {},
position: [0, 0],
} as LogEntry['node'],
runData: sessionRunData,
}),
};
const { link } = runWithRouter(logEntry, true);
expect(link()).toBeNull();
});
it('returns null when run output has no session block', () => {
const noSession = {
...sessionRunData,
data: { main: [[{ json: { response: 'hi' } }]] },
} as unknown as ITaskData;
const logEntry = { value: makeLogEntry({ runData: noSession }) };
const { link } = runWithRouter(logEntry, true);
expect(link()).toBeNull();
});
it('returns null when the session route is not registered (graceful fallback)', () => {
const logEntry = { value: makeLogEntry({ runData: sessionRunData }) };
const { link } = runWithRouter(logEntry, false);
expect(link()).toBeNull();
});
});

View File

@ -0,0 +1,96 @@
import { computed, type ComputedRef } from 'vue';
import { useRouter } from 'vue-router';
import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes';
import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants';
import type { LogEntry } from '@/features/execution/logs/logs.types';
/**
* Session identifiers the MessageAnAgent node emits in its output JSON. Kept
* structural so we don't have to import the runtime type from `n8n-workflow`
* just to read three string fields.
*/
type MessageAgentSession = {
agentId: string;
projectId: string;
sessionId: string;
};
function isMessageAgentSession(value: unknown): value is MessageAgentSession {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return (
typeof v.agentId === 'string' &&
typeof v.projectId === 'string' &&
typeof v.sessionId === 'string' &&
v.agentId !== '' &&
v.projectId !== '' &&
v.sessionId !== ''
);
}
function extractSession(logEntry: LogEntry | undefined): MessageAgentSession | null {
if (!logEntry) return null;
if (logEntry.node.type !== MESSAGE_AN_AGENT_NODE_TYPE) return null;
const main = logEntry.runData?.data?.main;
if (!Array.isArray(main)) return null;
for (const branch of main) {
if (!Array.isArray(branch)) continue;
for (const item of branch) {
const session = (item?.json as Record<string, unknown> | undefined)?.session;
if (isMessageAgentSession(session)) return session;
}
}
return null;
}
/**
* Given a log entry, expose a resolved session URL + opener for MessageAnAgent
* runs that emitted a `session` block in their output JSON. Returns `null` for
* any other node-type or runs missing the expected payload, so the caller can
* `v-if` straight off `link`.
*
* Opens in a new tab (matching n8n's other deep links from execution log) so
* the workflow execution view stays in place and so the link still works
* when the logs panel is popped out into its own window.
*/
export function useMessageAgentSessionLink(logEntry: ComputedRef<LogEntry | undefined>): {
link: ComputedRef<{ href: string; open: () => void } | null>;
} {
const router = useRouter();
const link = computed(() => {
const session = extractSession(logEntry.value);
if (!session) return null;
// Guard against the agents module not being mounted (or any router that
// doesn't know the route, e.g. in unit tests). `router.resolve` throws
// for unknown named routes — without this, the button would crash the
// log panel render in environments where agents aren't loaded.
let href: string;
try {
href = router.resolve({
name: AGENT_SESSION_DETAIL_VIEW,
params: {
projectId: session.projectId,
agentId: session.agentId,
threadId: session.sessionId,
},
}).href;
} catch {
return null;
}
return {
href,
open: () => {
window.open(href, '_blank', 'noopener');
},
};
});
return { link };
}

View File

@ -18,6 +18,8 @@ import type { LogEntry } from '../logs.types';
import { createTestLogEntry } from '../__test__/mocks';
import { createRunExecutionData, NodeConnectionTypes } from 'n8n-workflow';
import { HTML_NODE_TYPE } from '@/app/constants';
import { MESSAGE_AN_AGENT_NODE_TYPE } from '@/app/constants/nodeTypes';
import { AGENT_SESSION_DETAIL_VIEW } from '@/features/agents/constants';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
describe('LogDetailsPanel', () => {
@ -65,7 +67,14 @@ describe('LogDetailsPanel', () => {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
routes: [
{ path: '/', component: () => h('div') },
{
name: AGENT_SESSION_DETAIL_VIEW,
path: '/projects/:projectId/agents/:agentId/sessions/:threadId',
component: () => h('div'),
},
],
}),
pinia,
],
@ -198,6 +207,85 @@ describe('LogDetailsPanel', () => {
).toBeInTheDocument();
});
describe('messageAnAgent View Session button', () => {
const messageAgentNode = createTestNode({
name: 'Message an Agent',
type: MESSAGE_AN_AGENT_NODE_TYPE,
});
const messageAgentRunData = createTestTaskData({
executionStatus: 'success',
data: {
main: [
[
{
json: {
response: 'hi',
session: {
agentId: 'agent-1',
projectId: 'project-1',
sessionId: 'thread-1',
},
},
},
],
],
},
});
const baseProps = {
isOpen: true,
panels: LOG_DETAILS_PANEL_STATE.BOTH,
collapsingInputTableColumnName: null,
collapsingOutputTableColumnName: null,
isHeaderClickable: true,
};
it('renders a View Session button when run output carries a session block', () => {
const rendered = render({
...baseProps,
logEntry: createLogEntry({
node: messageAgentNode,
runIndex: 0,
runData: messageAgentRunData,
execution: createRunExecutionData({
resultData: { runData: { 'Message an Agent': [messageAgentRunData] } },
}),
}),
});
expect(rendered.queryByTestId('log-details-view-agent-session')).toBeInTheDocument();
});
it('does not render the button for nodes that are not messageAnAgent', () => {
const rendered = render({
...baseProps,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
});
expect(rendered.queryByTestId('log-details-view-agent-session')).not.toBeInTheDocument();
});
it('does not render the button when the session block is missing', () => {
const noSessionRunData = createTestTaskData({
executionStatus: 'success',
data: { main: [[{ json: { response: 'hi' } }]] },
});
const rendered = render({
...baseProps,
logEntry: createLogEntry({
node: messageAgentNode,
runIndex: 0,
runData: noSessionRunData,
execution: createRunExecutionData({
resultData: { runData: { 'Message an Agent': [noSessionRunData] } },
}),
}),
});
expect(rendered.queryByTestId('log-details-view-agent-session')).not.toBeInTheDocument();
});
});
it('should render output data in HTML mode for HTML node', async () => {
const nodeA = createTestNode({ name: 'A' });
const nodeB = createTestNode({

View File

@ -26,7 +26,8 @@ import { useExecutionRedaction } from '@/features/execution/executions/composabl
import { useUIStore } from '@/app/stores/ui.store';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants/modals';
import RedactedDataState from '@/features/ndv/panel/components/RedactedDataState.vue';
import { N8nButton, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nResizeWrapper, N8nText } from '@n8n/design-system';
import { useMessageAgentSessionLink } from '@/features/agents/composables/useMessageAgentSessionLink';
const MIN_IO_PANEL_WIDTH = 200;
const {
@ -69,6 +70,7 @@ const { isRedacted, canReveal, isDynamicCredentials, revealData } = useExecution
const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type));
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry, false));
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
const { link: messageAgentSessionLink } = useMessageAgentSessionLink(computed(() => logEntry));
const container = useTemplateRef<HTMLElement>('container');
const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
container,
@ -127,6 +129,16 @@ function handleResizeEnd() {
</template>
<template #actions>
<div v-if="isOpen && !isTriggerNode && !isPlaceholderLog(logEntry)" :class="$style.actions">
<N8nButton
v-if="messageAgentSessionLink"
variant="subtle"
size="xsmall"
data-test-id="log-details-view-agent-session"
@click.stop="messageAgentSessionLink.open()"
>
<N8nIcon icon="external-link" :class="$style.viewSessionIcon" />
{{ locale.baseText('logs.details.header.actions.viewAgentSession') }}
</N8nButton>
<KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['i'] }"
@ -255,6 +267,10 @@ function handleResizeEnd() {
margin-right: var(--spacing--2xs);
}
.viewSessionIcon {
margin-right: var(--spacing--3xs);
}
.executionSummary {
flex-shrink: 1;
}

View File

@ -70,6 +70,23 @@ export class MessageAnAgent implements INodeType {
rows: 4,
},
},
{
displayName: 'Advanced',
name: 'advanced',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Session ID',
name: 'sessionId',
type: 'string',
default: '',
description:
'Reuse an agent session to keep memory across runs. Leave empty to start a fresh session per execution.',
},
],
},
],
};
@ -116,6 +133,8 @@ export class MessageAnAgent implements INodeType {
};
const agentId = agentIdRlc.value;
const message = this.getNodeParameter('message', i) as string;
const advanced = this.getNodeParameter('advanced', i, {}) as { sessionId?: string };
const sessionIdOverride = advanced.sessionId?.trim();
if (!message.trim()) {
throw new NodeOperationError(this.getNode(), 'Message cannot be empty', {
@ -123,7 +142,12 @@ export class MessageAnAgent implements INodeType {
});
}
const result = await this.executeAgent({ agentId }, message, executionId, i);
const result = await this.executeAgent(
{ agentId, sessionId: sessionIdOverride || undefined },
message,
executionId,
i,
);
returnData.push({
json: {
@ -132,6 +156,7 @@ export class MessageAnAgent implements INodeType {
usage: result.usage as unknown as IDataObject,
toolCalls: result.toolCalls as unknown as IDataObject[],
finishReason: result.finishReason,
session: result.session as unknown as IDataObject,
},
pairedItem: { item: i },
});

View File

@ -8,6 +8,12 @@ describe('MessageAnAgent Node', () => {
let node: MessageAnAgent;
let executeFunctions: jest.Mocked<IExecuteFunctions>;
const mockSession = {
agentId: 'agent-1',
projectId: 'project-1',
sessionId: 'exec-123-0',
};
const mockAgentResult: ExecuteAgentData = {
response: 'Hello from agent',
structuredOutput: null,
@ -18,6 +24,7 @@ describe('MessageAnAgent Node', () => {
},
toolCalls: [],
finishReason: 'stop',
session: mockSession,
};
beforeEach(() => {
@ -39,17 +46,20 @@ describe('MessageAnAgent Node', () => {
it('should send a message and return the agent response', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return 'Hello agent';
return undefined;
});
executeFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex?: number, fallback?: unknown) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return 'Hello agent';
if (param === 'advanced') return fallback ?? {};
return undefined;
},
);
executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
const result = await node.execute.call(executeFunctions);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
{ agentId: 'agent-1' },
{ agentId: 'agent-1', sessionId: undefined },
'Hello agent',
'exec-123',
0,
@ -63,6 +73,7 @@ describe('MessageAnAgent Node', () => {
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
toolCalls: [],
finishReason: 'stop',
session: mockSession,
},
pairedItem: { item: 0 },
},
@ -70,13 +81,56 @@ describe('MessageAnAgent Node', () => {
]);
});
it('should throw NodeOperationError when message is empty', async () => {
it('should forward a user-supplied sessionId from the Advanced collection', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return ' ';
return undefined;
if (param === 'message') return 'Hello agent';
if (param === 'advanced') return { sessionId: ' thread-42 ' };
return undefined as unknown as string;
});
executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
await node.execute.call(executeFunctions);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
{ agentId: 'agent-1', sessionId: 'thread-42' },
'Hello agent',
'exec-123',
0,
);
});
it('should treat a whitespace-only sessionId as no override', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return 'Hello agent';
if (param === 'advanced') return { sessionId: ' ' };
return undefined as unknown as string;
});
executeFunctions.executeAgent.mockResolvedValue(mockAgentResult);
await node.execute.call(executeFunctions);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
{ agentId: 'agent-1', sessionId: undefined },
'Hello agent',
'exec-123',
0,
);
});
it('should throw NodeOperationError when message is empty', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex?: number, fallback?: unknown) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return ' ';
if (param === 'advanced') return fallback ?? {};
return undefined;
},
);
executeFunctions.continueOnFail.mockReturnValue(false);
await expect(node.execute.call(executeFunctions)).rejects.toThrow(NodeOperationError);
@ -85,11 +139,14 @@ describe('MessageAnAgent Node', () => {
it('should process multiple items with different itemIndex values', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string, itemIndex: number) => {
if (param === 'agentId') return { mode: 'id', value: `agent-${itemIndex + 1}` };
if (param === 'message') return `Message ${itemIndex + 1}`;
return undefined;
});
executeFunctions.getNodeParameter.mockImplementation(
(param: string, itemIndex?: number, fallback?: unknown) => {
if (param === 'agentId') return { mode: 'id', value: `agent-${(itemIndex ?? 0) + 1}` };
if (param === 'message') return `Message ${(itemIndex ?? 0) + 1}`;
if (param === 'advanced') return fallback ?? {};
return undefined;
},
);
const resultForItem0: ExecuteAgentData = {
...mockAgentResult,
@ -108,13 +165,13 @@ describe('MessageAnAgent Node', () => {
expect(executeFunctions.executeAgent).toHaveBeenCalledTimes(2);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
{ agentId: 'agent-1' },
{ agentId: 'agent-1', sessionId: undefined },
'Message 1',
'exec-123',
0,
);
expect(executeFunctions.executeAgent).toHaveBeenCalledWith(
{ agentId: 'agent-2' },
{ agentId: 'agent-2', sessionId: undefined },
'Message 2',
'exec-123',
1,
@ -128,11 +185,14 @@ describe('MessageAnAgent Node', () => {
it('should return error item instead of throwing when continueOnFail is true', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return 'Hello';
return undefined;
});
executeFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex?: number, fallback?: unknown) => {
if (param === 'agentId') return { mode: 'id', value: 'agent-1' };
if (param === 'message') return 'Hello';
if (param === 'advanced') return fallback ?? {};
return undefined;
},
);
executeFunctions.continueOnFail.mockReturnValue(true);
executeFunctions.executeAgent.mockRejectedValue(new Error('Agent unavailable'));
@ -156,11 +216,14 @@ describe('MessageAnAgent Node', () => {
};
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'agentId') return { mode: 'list', value: 'agent-1' };
if (param === 'message') return 'Structured query';
return undefined;
});
executeFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex?: number, fallback?: unknown) => {
if (param === 'agentId') return { mode: 'list', value: 'agent-1' };
if (param === 'message') return 'Structured query';
if (param === 'advanced') return fallback ?? {};
return undefined;
},
);
executeFunctions.executeAgent.mockResolvedValue(structuredResult);
const result = await node.execute.call(executeFunctions);

View File

@ -1950,6 +1950,13 @@ export interface ExecuteWorkflowData {
export interface ExecuteAgentInfo {
/** The agent ID to execute. */
agentId: string;
/**
* Optional caller-supplied session id. When set, this becomes the agent
* thread id, letting workflows continue the same conversation (and reuse
* memory) across executions. When omitted, a per-call thread is derived
* from the workflow execution id and item index.
*/
sessionId?: string;
}
export interface ExecuteAgentOptions {
@ -1978,6 +1985,17 @@ export interface ExecuteAgentData {
}>;
/** Why the agent stopped. */
finishReason: string;
/**
* Identifiers of the agent session this call wrote to. Surfaced so the
* caller (e.g. the MessageAnAgent node) can link from a workflow execution
* back to the agent session detail view.
*/
session: {
agentId: string;
projectId: string;
/** The threadId persisted to the agent session. May be a caller-provided override. */
sessionId: string;
};
}
export type WebhookSetupMethodNames = 'checkExists' | 'create' | 'delete';