mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Show workflow-triggered runs in agent session history (no-changelog) (#29932)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f1f42d180
commit
ebafde7f85
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -336,6 +336,28 @@ export class AgentsService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same scoping as {@link findByUser}, but only returns agents that have a
|
||||
* `publishedVersion`. Used by the MessageAnAgent node's listSearch so the
|
||||
* dropdown can't surface unpublished agents — `executeForWorkflow` rejects
|
||||
* those at runtime, and showing them would just lead to a confusing
|
||||
* "Agent is not published" error after the user picks one.
|
||||
*/
|
||||
async findPublishedByUser(userId: string): Promise<Agent[]> {
|
||||
const projectRelations = await this.projectRelationRepository.findAllByUser(userId);
|
||||
const projectIds = projectRelations.map((pr) => pr.projectId);
|
||||
|
||||
if (projectIds.length === 0) return [];
|
||||
|
||||
const agents = await this.agentRepository.find({
|
||||
where: { projectId: In(projectIds) },
|
||||
relations: { publishedVersion: true },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
|
||||
return agents.filter((agent) => agent.publishedVersion);
|
||||
}
|
||||
|
||||
async publishAgent(agentId: string, projectId: string, userId: string): Promise<Agent> {
|
||||
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
|
||||
if (!agent) {
|
||||
|
|
@ -1039,8 +1061,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,
|
||||
|
|
@ -1071,77 +1100,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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -295,7 +295,11 @@ export async function executeAgent(
|
|||
async function listAgents(userId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const { AgentsService } = await import('@/modules/agents/agents.service');
|
||||
const agentsService = Container.get(AgentsService);
|
||||
const agents = await agentsService.findByUser(userId);
|
||||
// Only published agents are runnable from a workflow — see the publish
|
||||
// guard in `executeForWorkflow`. Filtering here keeps unpublished agents
|
||||
// out of the MessageAnAgent dropdown so users don't pick one that would
|
||||
// fail at execution time.
|
||||
const agents = await agentsService.findPublishedByUser(userId);
|
||||
return agents.map((agent) => ({ id: agent.id, name: agent.name }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ export class MessageAnAgent implements INodeType {
|
|||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
'Only published agents are listed below. Publish an agent before referencing it from a workflow.',
|
||||
name: 'publishedAgentNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Agent',
|
||||
name: 'agentId',
|
||||
|
|
@ -70,6 +77,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 +140,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 +149,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 +163,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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user