mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-30 19:35:36 +08:00
feat(ai-builder): Add Fix with Assistant for failed agent preview tools - pass single execution context (#34616)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
cubic-dev-ai[bot]
parent
b93a99945b
commit
96268910d3
@@ -105,4 +105,4 @@ export type AgentSseEvent =
|
||||
/** Backend-emitted ids of the missing config slots; only set when `errorCode` is `agent_misconfigured`. */
|
||||
missing?: string[];
|
||||
}
|
||||
| { type: 'done'; sessionId?: string };
|
||||
| { type: 'done'; sessionId?: string; executionId?: string };
|
||||
|
||||
@@ -210,6 +210,8 @@ export interface AgentPersistedMessageDto {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | (string & {});
|
||||
content: AgentPersistedMessageContentPart[];
|
||||
/** Agent-execution turn id when this message was produced from an execution transcript. */
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-6' as const;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { AgentChatController } from '../agent-chat.controller';
|
||||
import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service';
|
||||
import type { AgentExecutionService } from '../agent-execution.service';
|
||||
import type { FlushableResponse } from '../agent-sse-stream';
|
||||
import type { AgentTestChatService } from '../agent-test-chat.service';
|
||||
import type { AgentsService } from '../agents.service';
|
||||
import type { AgentValidationService } from '../agent-validation.service';
|
||||
@@ -21,18 +22,25 @@ function makeController() {
|
||||
mock<Pick<AgentsService, 'findById' | 'findByProjectId' | 'findByProjectIdPaginated'>>();
|
||||
const agentExecutionOrchestratorService = mock<AgentExecutionOrchestratorService>();
|
||||
const agentsBuilderService = mock<AgentsBuilderService>();
|
||||
const agentValidationService = mock<AgentValidationService>();
|
||||
const agentExecutionService = mock<AgentExecutionService>();
|
||||
agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] });
|
||||
|
||||
const controller = new AgentChatController(
|
||||
agentExecutionOrchestratorService,
|
||||
mock<AgentTestChatService>(),
|
||||
mock<AgentValidationService>(),
|
||||
agentValidationService,
|
||||
agentsBuilderService,
|
||||
mock<CredentialsService>(),
|
||||
mock<AgentExecutionService>(),
|
||||
agentExecutionService,
|
||||
agentsService as unknown as AgentsService,
|
||||
);
|
||||
|
||||
return {
|
||||
controller,
|
||||
agentExecutionOrchestratorService,
|
||||
agentValidationService,
|
||||
agentExecutionService,
|
||||
agentsService: {
|
||||
findById: agentsService.findById,
|
||||
getConversationHistory: agentExecutionOrchestratorService.getConversationHistory,
|
||||
@@ -114,3 +122,70 @@ describe('AgentChatController chat message history', () => {
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentChatController SSE done payload', () => {
|
||||
it('includes executionId on done when recorded', async () => {
|
||||
const { controller, agentExecutionOrchestratorService, agentExecutionService } =
|
||||
makeController();
|
||||
agentExecutionService.findThreadById.mockResolvedValue(null);
|
||||
agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) {
|
||||
config.onExecutionRecorded?.('exec-99');
|
||||
yield* [];
|
||||
});
|
||||
|
||||
const writes: string[] = [];
|
||||
const res = mock<FlushableResponse>();
|
||||
res.write.mockImplementation((chunk: string) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await controller.chat(
|
||||
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
|
||||
res,
|
||||
'agent-1',
|
||||
{ message: 'hi', sessionId: 'thread-1' } as never,
|
||||
);
|
||||
|
||||
const events = writes
|
||||
.filter((line) => line.startsWith('data: '))
|
||||
.map((line) => JSON.parse(line.slice(6).trim()) as { type: string });
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'done',
|
||||
sessionId: 'thread-1',
|
||||
executionId: 'exec-99',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes executionId on resume done when recorded', async () => {
|
||||
const { controller, agentExecutionOrchestratorService } = makeController();
|
||||
agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) {
|
||||
config.onExecutionRecorded?.('exec-resume-1');
|
||||
yield* [];
|
||||
});
|
||||
|
||||
const writes: string[] = [];
|
||||
const res = mock<FlushableResponse>();
|
||||
res.write.mockImplementation((chunk: string) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await controller.chatResume(
|
||||
{ params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never,
|
||||
res,
|
||||
'agent-1',
|
||||
{ runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } } as never,
|
||||
);
|
||||
|
||||
const events = writes
|
||||
.filter((line) => line.startsWith('data: '))
|
||||
.map((line) => JSON.parse(line.slice(6).trim()) as { type: string });
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'done',
|
||||
executionId: 'exec-resume-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+48
-1
@@ -174,6 +174,47 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('awaits recordMessage and notifies onExecutionRecorded with the returned id', async () => {
|
||||
const { service, executionService } = makeService();
|
||||
const runtime = makeRuntime([{ type: 'finish', finishReason: 'stop' }]);
|
||||
const onExecutionRecorded = vi.fn();
|
||||
|
||||
await collect(
|
||||
service.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId,
|
||||
message: 'hello',
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
onExecutionRecorded,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executionService.recordMessage).toHaveBeenCalled();
|
||||
expect(onExecutionRecorded).toHaveBeenCalledWith('execution-1');
|
||||
});
|
||||
|
||||
it('still records the message when onExecutionRecorded is omitted', async () => {
|
||||
const { service, executionService } = makeService();
|
||||
const runtime = makeRuntime([{ type: 'finish', finishReason: 'stop' }]);
|
||||
|
||||
await collect(
|
||||
service.streamChatResponse({
|
||||
agentInstance: runtime.agent,
|
||||
toolRegistry: runtime.toolRegistry,
|
||||
agentId,
|
||||
userId,
|
||||
message: 'hello',
|
||||
memory: { threadId: 'thread-1', resourceId: 'resource-1' },
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executionService.recordMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes in-app chat against the draft runtime', async () => {
|
||||
const {
|
||||
service,
|
||||
@@ -460,9 +501,15 @@ describe('AgentExecutionOrchestratorService', () => {
|
||||
await expect(
|
||||
service.getConversationHistory({ threadId: 'thread-1', projectId, agentId }),
|
||||
).resolves.toEqual([
|
||||
{ id: 'execution-1:user', role: 'user', content: [{ type: 'text', text: 'Hi' }] },
|
||||
{
|
||||
id: 'execution-1:user',
|
||||
executionId: 'execution-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hi' }],
|
||||
},
|
||||
{
|
||||
id: 'execution-1:assistant',
|
||||
executionId: 'execution-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('execution-to-message-mapper', () => {
|
||||
id: 'execution-1:user',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
{
|
||||
id: 'execution-1:assistant',
|
||||
@@ -60,6 +61,7 @@ describe('execution-to-message-mapper', () => {
|
||||
},
|
||||
{ type: 'text', text: 'Done.' },
|
||||
],
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -88,6 +90,7 @@ describe('execution-to-message-mapper', () => {
|
||||
id: 'execution-1:user',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
{
|
||||
id: 'execution-1:assistant',
|
||||
@@ -104,6 +107,7 @@ describe('execution-to-message-mapper', () => {
|
||||
error: 'Tool failed',
|
||||
},
|
||||
],
|
||||
executionId: 'execution-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -185,6 +189,7 @@ describe('execution-to-message-mapper', () => {
|
||||
id: 'execution-suspended:user',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Show me an action' }],
|
||||
executionId: 'execution-suspended',
|
||||
},
|
||||
{
|
||||
id: 'execution-suspended:assistant',
|
||||
@@ -212,11 +217,13 @@ describe('execution-to-message-mapper', () => {
|
||||
output: { type: 'button', value: 'approve' },
|
||||
},
|
||||
],
|
||||
executionId: 'execution-suspended',
|
||||
},
|
||||
{
|
||||
id: 'execution-resumed:assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Approved.' }],
|
||||
executionId: 'execution-resumed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export class AgentChatController {
|
||||
}
|
||||
|
||||
try {
|
||||
let executionId: string | undefined;
|
||||
const suspended = await pumpChunks(
|
||||
this.agentExecutionOrchestratorService.executeForChat({
|
||||
agentId,
|
||||
@@ -96,11 +97,14 @@ export class AgentChatController {
|
||||
threadId,
|
||||
resourceId: draftChatMemoryResourceId(req.user.id),
|
||||
},
|
||||
onExecutionRecorded: (id) => {
|
||||
executionId = id;
|
||||
},
|
||||
}),
|
||||
send,
|
||||
);
|
||||
if (!suspended) {
|
||||
send({ type: 'done', sessionId: threadId });
|
||||
send({ type: 'done', sessionId: threadId, ...(executionId ? { executionId } : {}) });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Chat failed';
|
||||
@@ -123,6 +127,7 @@ export class AgentChatController {
|
||||
const { send } = initSseStream(res);
|
||||
|
||||
try {
|
||||
let executionId: string | undefined;
|
||||
const suspended = await pumpChunks(
|
||||
this.agentExecutionOrchestratorService.resumeForChat({
|
||||
agentId,
|
||||
@@ -133,11 +138,14 @@ export class AgentChatController {
|
||||
user: req.user,
|
||||
usePublishedVersion: false,
|
||||
integrationType: N8N_CHAT_INTEGRATION_TYPE,
|
||||
onExecutionRecorded: (id) => {
|
||||
executionId = id;
|
||||
},
|
||||
}),
|
||||
send,
|
||||
);
|
||||
if (!suspended) {
|
||||
send({ type: 'done' });
|
||||
send({ type: 'done', ...(executionId ? { executionId } : {}) });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Resume failed';
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { AgentExecutionService } from './agent-execution.service';
|
||||
import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { AgentExecutionService, type RecordMessageParams } from './agent-execution.service';
|
||||
import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service';
|
||||
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
|
||||
import { ExecutionRecorder } from './execution-recorder';
|
||||
@@ -18,9 +20,6 @@ import { createAgentExecutionCounter } from './utils/agent-execution-counter';
|
||||
import { streamAgentChunks } from './utils/agent-stream';
|
||||
import { executionsToMessagesDto } from './utils/execution-to-message-mapper';
|
||||
|
||||
import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
export interface AgentMemoryScope {
|
||||
threadId: string;
|
||||
resourceId: string;
|
||||
@@ -39,6 +38,8 @@ export interface ExecuteForChatConfig {
|
||||
user: User;
|
||||
/** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */
|
||||
memory: AgentMemoryScope;
|
||||
/** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */
|
||||
onExecutionRecorded?: (executionId: string) => void;
|
||||
}
|
||||
|
||||
export interface ExecuteForChatPublishedConfig {
|
||||
@@ -78,6 +79,8 @@ export interface ResumeForChatConfig {
|
||||
* persisted tool call references a tool the rebuilt runtime doesn't know.
|
||||
*/
|
||||
integrationType?: string;
|
||||
/** Fired after the resumed turn is persisted; used to attach `executionId` to SSE `done`. */
|
||||
onExecutionRecorded?: (executionId: string) => void;
|
||||
}
|
||||
|
||||
export interface ExecuteForTaskPublishedConfig {
|
||||
@@ -129,6 +132,8 @@ export interface StreamChatResponseConfig {
|
||||
runType: AgentRunTelemetryType;
|
||||
configuration: IAgentConfigurationTelemetryProperties;
|
||||
};
|
||||
/** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */
|
||||
onExecutionRecorded?: (executionId: string) => void;
|
||||
}
|
||||
|
||||
function getMaxIterationsChunks(): StreamChunk[] {
|
||||
@@ -197,6 +202,7 @@ export class AgentExecutionOrchestratorService {
|
||||
integrationType,
|
||||
user,
|
||||
usePublishedVersion = true,
|
||||
onExecutionRecorded,
|
||||
} = config;
|
||||
|
||||
const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId);
|
||||
@@ -275,8 +281,10 @@ export class AgentExecutionOrchestratorService {
|
||||
// or fail while streaming. Don't repeat the original user message — the
|
||||
// pre-suspension execution already has it.
|
||||
const messageRecord = recorder.getMessageRecord();
|
||||
void this.agentExecutionService
|
||||
.recordMessage({
|
||||
await this.persistRecordedExecution({
|
||||
onExecutionRecorded,
|
||||
failureMessage: 'Failed to record resumed agent execution',
|
||||
params: {
|
||||
threadId,
|
||||
agentId,
|
||||
agentName: agentInstance.name,
|
||||
@@ -288,14 +296,8 @@ export class AgentExecutionOrchestratorService {
|
||||
runType,
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn('Failed to record resumed agent execution', {
|
||||
agentId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +305,7 @@ export class AgentExecutionOrchestratorService {
|
||||
* Execute an agent for the in-app test chat and yield stream chunks.
|
||||
*/
|
||||
async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator<StreamChunk> {
|
||||
const { agentId, projectId, message, user, memory } = config;
|
||||
const { agentId, projectId, message, user, memory, onExecutionRecorded } = config;
|
||||
|
||||
// `user` is always set (see ExecuteForChatConfig) — this builds/reuses a
|
||||
// runtime scoped to this specific user's tool access.
|
||||
@@ -334,6 +336,7 @@ export class AgentExecutionOrchestratorService {
|
||||
runType: 'test',
|
||||
configuration: runtime.telemetryConfiguration,
|
||||
},
|
||||
onExecutionRecorded,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,6 +462,7 @@ export class AgentExecutionOrchestratorService {
|
||||
taskId,
|
||||
taskVersionId,
|
||||
telemetry,
|
||||
onExecutionRecorded,
|
||||
} = config;
|
||||
const { threadId, resourceId } = memory;
|
||||
|
||||
@@ -505,8 +509,10 @@ export class AgentExecutionOrchestratorService {
|
||||
// Always record — even if suspended or failed, the pre-suspension/error
|
||||
// response text and tool calls are valuable.
|
||||
const messageRecord = recorder.getMessageRecord();
|
||||
void this.agentExecutionService
|
||||
.recordMessage({
|
||||
await this.persistRecordedExecution({
|
||||
onExecutionRecorded,
|
||||
failureMessage: 'Failed to record agent execution',
|
||||
params: {
|
||||
threadId,
|
||||
agentId,
|
||||
agentName: agentInstance.name,
|
||||
@@ -518,14 +524,40 @@ export class AgentExecutionOrchestratorService {
|
||||
taskId,
|
||||
taskVersionId,
|
||||
telemetry,
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn('Failed to record agent execution', {
|
||||
agentId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a streamed turn. Awaits when `onExecutionRecorded` is set so SSE
|
||||
* `done` can carry executionId; otherwise fire-and-forget.
|
||||
*/
|
||||
private async persistRecordedExecution(args: {
|
||||
onExecutionRecorded?: (executionId: string) => void;
|
||||
params: RecordMessageParams;
|
||||
failureMessage: string;
|
||||
}): Promise<void> {
|
||||
const { onExecutionRecorded, params, failureMessage } = args;
|
||||
const persist = async () => {
|
||||
const executionId = await this.agentExecutionService.recordMessage(params);
|
||||
onExecutionRecorded?.(executionId);
|
||||
};
|
||||
const logFailure = (error: unknown) => {
|
||||
this.logger.warn(failureMessage, {
|
||||
agentId: params.agentId,
|
||||
threadId: params.threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
if (onExecutionRecorded) {
|
||||
try {
|
||||
await persist();
|
||||
} catch (error) {
|
||||
logFailure(error);
|
||||
}
|
||||
} else {
|
||||
void persist().catch(logFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function textMessageDto(
|
||||
id: string,
|
||||
role: AgentPersistedMessageDto['role'],
|
||||
text: string | null,
|
||||
executionId: string,
|
||||
): AgentPersistedMessageDto | null {
|
||||
if (!text) return null;
|
||||
const contentPart = textPart(text);
|
||||
@@ -30,6 +31,7 @@ function textMessageDto(
|
||||
id,
|
||||
role,
|
||||
content: [contentPart],
|
||||
executionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,7 +137,15 @@ function assistantContentFromExecution(
|
||||
export function executionToMessagesDto(execution: ExecutionTranscript): AgentPersistedMessageDto[] {
|
||||
const messages: AgentPersistedMessageDto[] = [];
|
||||
|
||||
const userMessage = textMessageDto(`${execution.id}:user`, 'user', execution.userMessage);
|
||||
// Message `id` stays `${execution.id}:role` for stable client keys. Turn
|
||||
// scope for handoff/history is the explicit `executionId` field — do not
|
||||
// make consumers parse it back out of `id`.
|
||||
const userMessage = textMessageDto(
|
||||
`${execution.id}:user`,
|
||||
'user',
|
||||
execution.userMessage,
|
||||
execution.id,
|
||||
);
|
||||
if (userMessage) messages.push(userMessage);
|
||||
|
||||
const assistantContent = assistantContentFromExecution(execution);
|
||||
@@ -144,6 +154,7 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer
|
||||
id: `${execution.id}:assistant`,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
executionId: execution.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -46,6 +46,31 @@ describe('N8nAiActivityStep', () => {
|
||||
expect(getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide the inner error callout when hideErrorCallout is true', () => {
|
||||
const { container } = render(AiActivityStep, {
|
||||
props: {
|
||||
label: 'Search nodes',
|
||||
error: 'Something went wrong',
|
||||
hideErrorCallout: true,
|
||||
},
|
||||
global: {
|
||||
...global,
|
||||
stubs: {
|
||||
...global.stubs,
|
||||
N8nCallout: {
|
||||
template: '<div data-test-id="activity-error-callout"><slot /></div>',
|
||||
},
|
||||
N8nTooltip: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-test-id="activity-error-callout"]'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.n8n-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render slot content', () => {
|
||||
const { getByText } = render(AiActivityStep, {
|
||||
props: { label: 'Search nodes' },
|
||||
|
||||
+8
-1
@@ -21,12 +21,15 @@ const props = withDefaults(
|
||||
hasContent?: boolean;
|
||||
/** Wraps slot content in the standard activity data panel. */
|
||||
wrapContent?: boolean;
|
||||
/** Keep error icon/tooltip but skip the inner danger callout. */
|
||||
hideErrorCallout?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
error: undefined,
|
||||
hasContent: true,
|
||||
wrapContent: false,
|
||||
hideErrorCallout: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -71,7 +74,11 @@ const errorTooltip = computed(() =>
|
||||
<slot />
|
||||
</N8nAiActivityStepResultSection>
|
||||
<slot v-else />
|
||||
<N8nCallout v-if="props.error !== undefined" theme="danger" :class="$style.errorCallout">
|
||||
<N8nCallout
|
||||
v-if="props.error !== undefined && !props.hideErrorCallout"
|
||||
theme="danger"
|
||||
:class="$style.errorCallout"
|
||||
>
|
||||
{{ props.error }}
|
||||
</N8nCallout>
|
||||
</N8nAnimatedCollapsibleContent>
|
||||
|
||||
@@ -635,6 +635,7 @@
|
||||
"chatHub.message.actions.readAloud": "Read aloud",
|
||||
"chatHub.message.actions.stopReading": "Stop reading",
|
||||
"agents.builder.preview.sendToAssistant": "Send to assistant",
|
||||
"agents.builder.preview.fixWithAssistant": "Fix with Assistant",
|
||||
"chatHub.message.actions.edit": "Edit",
|
||||
"chatHub.message.actions.regenerate": "Regenerate",
|
||||
"chatHub.message.actions.executionId": "Execution ID",
|
||||
@@ -6962,6 +6963,7 @@
|
||||
"agents.builder.chat.newChat.label": "New chat",
|
||||
"agents.chat.loadHistory.error": "Could not load chat history",
|
||||
"agents.chat.clearHistory.error": "Could not clear chat history",
|
||||
"agents.chat.toolError.generic": "Something went wrong while running this tool",
|
||||
"agents.chat.clearHistory": "Clear chat history",
|
||||
"agents.chat.input.placeholder": "Type a message...",
|
||||
"agents.preview.episodicMemoryCallout.text": "Access memories from previous conversations.",
|
||||
|
||||
@@ -680,6 +680,27 @@ describe('AgentBuilderView — preview routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards executionId from preview Fix with Assistant to instance AI handoff', async () => {
|
||||
routeName = 'AgentPreviewView';
|
||||
routeQuery.continueSessionId = 'thread-1';
|
||||
|
||||
const wrapper = await renderView();
|
||||
const preview = wrapper.findComponent({ name: 'AgentPreviewChatPage' });
|
||||
|
||||
preview.vm.$emit('send-to-assistant', 'exec-turn-1');
|
||||
await flushPromises();
|
||||
|
||||
expect(sendPreviewSessionToInstanceAiMock).toHaveBeenCalledWith({
|
||||
projectId: 'p1',
|
||||
agentId: 'a1',
|
||||
threadId: 'thread-1',
|
||||
agentName: 'Agent One',
|
||||
agentIcon: 'bot',
|
||||
sessionTitle: 'agents.builder.chat.newChat.label',
|
||||
executionId: 'exec-turn-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks knowledge file uploads that would exceed the total size limit', async () => {
|
||||
getAgentMock.mockResolvedValue(makeAgentResponse({ activeVersionId: 'v1' }));
|
||||
listAgentFilesMock.mockResolvedValue([
|
||||
|
||||
+45
-2
@@ -32,7 +32,12 @@ vi.mock('@/features/ai/chatHub/components/ChatTypingIndicator.vue', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/AgentChatToolSteps.vue', () => ({
|
||||
default: { template: '<div />', props: ['toolCalls', 'projectId'] },
|
||||
default: {
|
||||
name: 'AgentChatToolSteps',
|
||||
template: '<button data-test-id="tool-steps-fix-stub" @click="$emit(\'fixWithAssistant\')" />',
|
||||
props: ['toolCalls', 'projectId', 'canFixWithAssistant', 'executionId'],
|
||||
emits: ['fixWithAssistant'],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/components/interactive/InteractiveCard.vue', () => ({
|
||||
@@ -107,6 +112,7 @@ describe('AgentChatMessageList', () => {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Agent reply',
|
||||
executionId: 'exec-should-not-forward',
|
||||
status: 'success',
|
||||
} satisfies ChatMessage,
|
||||
],
|
||||
@@ -123,7 +129,44 @@ describe('AgentChatMessageList', () => {
|
||||
|
||||
await wrapper.find('[data-test-id="agent-chat-message-send-to-assistant"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('sendToAssistant')).toHaveLength(1);
|
||||
// Whole-session share must not forward message executionId.
|
||||
expect(wrapper.emitted('sendToAssistant')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('forwards Fix with Assistant with toolRun executionId', async () => {
|
||||
const wrapper = mount(AgentChatMessageList, {
|
||||
props: {
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-turn-1',
|
||||
toolCalls: [
|
||||
{
|
||||
tool: 'http_request',
|
||||
toolCallId: 'tc-1',
|
||||
state: 'error',
|
||||
output: 'boom',
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
} satisfies ChatMessage,
|
||||
],
|
||||
messagingState: 'idle',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'thread-1',
|
||||
canSendToAssistant: true,
|
||||
},
|
||||
});
|
||||
|
||||
const toolSteps = wrapper.findComponent({ name: 'AgentChatToolSteps' });
|
||||
expect(toolSteps.props('canFixWithAssistant')).toBe(true);
|
||||
expect(toolSteps.props('executionId')).toBe('exec-turn-1');
|
||||
|
||||
await wrapper.find('[data-test-id="tool-steps-fix-stub"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('sendToAssistant')).toEqual([['exec-turn-1']]);
|
||||
});
|
||||
|
||||
it('does not render actions for user text messages', () => {
|
||||
|
||||
+102
-4
@@ -8,7 +8,7 @@ import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool';
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nAiActivityStep: {
|
||||
props: ['label', 'hasContent', 'loading', 'error'],
|
||||
props: ['label', 'hasContent', 'loading', 'error', 'hideErrorCallout'],
|
||||
data: () => ({ isOpen: false }),
|
||||
computed: {
|
||||
labelParts(this: { label: string }): string[] {
|
||||
@@ -25,13 +25,24 @@ vi.mock('@n8n/design-system', () => ({
|
||||
<span> · </span>
|
||||
<span data-testid="tool-step-summary">{{ part }}</span>
|
||||
</template>
|
||||
<div v-if="error && !hideErrorCallout" data-test-id="tool-step-error">{{ error }}</div>
|
||||
<div v-if="isOpen"><slot /></div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
N8nAiActivityStepGroup: {
|
||||
props: ['label', 'size', 'loading'],
|
||||
template: '<div><slot /></div>',
|
||||
template: '<div data-test-id="n8n-ai-activity-step-group"><slot /></div>',
|
||||
},
|
||||
N8nButton: {
|
||||
props: ['size', 'variant'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button type="button" @click.stop="$emit(\'click\')"><slot name="icon" /><slot /></button>',
|
||||
},
|
||||
N8nCallout: {
|
||||
props: ['theme'],
|
||||
template: '<div><slot /><slot name="trailingContent" /></div>',
|
||||
},
|
||||
N8nIcon: {
|
||||
template: '<i :data-icon="icon" />',
|
||||
@@ -81,9 +92,12 @@ vi.mock('../composables/useSubAgentNames', () => ({
|
||||
useSubAgentNames: () => ({ subAgentNameById: { value: new Map() } }),
|
||||
}));
|
||||
|
||||
function mountSteps(toolCalls: ToolCall[]) {
|
||||
function mountSteps(
|
||||
toolCalls: ToolCall[],
|
||||
extra: { canFixWithAssistant?: boolean; executionId?: string } = {},
|
||||
) {
|
||||
return mount(AgentChatToolSteps, {
|
||||
props: { toolCalls, projectId: 'project-1' },
|
||||
props: { toolCalls, projectId: 'project-1', ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +133,90 @@ describe('AgentChatToolSteps', () => {
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows Fix with Assistant only for errored tools when handoff is enabled', async () => {
|
||||
const errored: ToolCall = {
|
||||
tool: 'search_nodes',
|
||||
toolCallId: 'tc-err',
|
||||
state: TOOL_CALL_STATE.ERROR,
|
||||
output: 'Tool failed',
|
||||
};
|
||||
|
||||
const withoutFix = mountSteps([errored]);
|
||||
expect(withoutFix.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const withoutExecutionId = mountSteps([errored], { canFixWithAssistant: true });
|
||||
expect(
|
||||
withoutExecutionId.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists(),
|
||||
).toBe(false);
|
||||
|
||||
const doneTool = mountSteps(
|
||||
[{ ...errored, state: TOOL_CALL_STATE.DONE, toolCallId: 'tc-ok' }],
|
||||
{ canFixWithAssistant: true, executionId: 'exec-1' },
|
||||
);
|
||||
expect(doneTool.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const withFix = mountSteps([errored], {
|
||||
canFixWithAssistant: true,
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
expect(
|
||||
withFix.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists(),
|
||||
).toBe(true);
|
||||
await withFix.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').trigger('click');
|
||||
expect(withFix.emitted('fixWithAssistant')?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows a generic error when the failed tool output is empty', () => {
|
||||
const wrapper = mountSteps([
|
||||
{
|
||||
tool: 'search_nodes',
|
||||
toolCallId: 'tc-err',
|
||||
state: TOOL_CALL_STATE.ERROR,
|
||||
output: {},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(wrapper.find('[data-test-id="tool-step-error"]').text()).toBe(
|
||||
'agents.chat.toolError.generic',
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('{}');
|
||||
});
|
||||
|
||||
it('shows Fix with Assistant outside the tool group when a multi-tool turn has an error', () => {
|
||||
const wrapper = mountSteps(
|
||||
[
|
||||
{
|
||||
tool: 'search_nodes',
|
||||
toolCallId: 'tc-ok',
|
||||
state: TOOL_CALL_STATE.DONE,
|
||||
output: { nodes: ['Slack'] },
|
||||
},
|
||||
{
|
||||
tool: 'search_nodes',
|
||||
toolCallId: 'tc-err',
|
||||
state: TOOL_CALL_STATE.ERROR,
|
||||
output: 'Tool failed',
|
||||
},
|
||||
],
|
||||
{ canFixWithAssistant: true, executionId: 'exec-1' },
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(true);
|
||||
|
||||
const group = wrapper.find('[data-test-id="n8n-ai-activity-step-group"]');
|
||||
expect(group.exists()).toBe(true);
|
||||
expect(group.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows incomplete task count in write_todos summary', async () => {
|
||||
const wrapper = mountSteps([
|
||||
{
|
||||
|
||||
@@ -70,6 +70,31 @@ describe('rebuildInteractiveFromHistory', () => {
|
||||
});
|
||||
|
||||
describe('convertDbMessages — interactive turn synthesis', () => {
|
||||
it('uses executionId from the persisted message dto', () => {
|
||||
const chat = convertDbMessages([
|
||||
{
|
||||
id: 'exec-1:assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi' }],
|
||||
executionId: 'exec-1',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(chat[0].executionId).toBe('exec-1');
|
||||
});
|
||||
|
||||
it('does not invent executionId from the message id when the dto omits it', () => {
|
||||
const chat = convertDbMessages([
|
||||
{
|
||||
id: 'exec-1:assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi' }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(chat[0].executionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves multiple resolved n8n chat cards from one persisted assistant message', () => {
|
||||
const dbMessages: AgentPersistedMessageDto[] = [
|
||||
{
|
||||
|
||||
@@ -920,3 +920,22 @@ describe('useAgentChatStream — loadHistory', () => {
|
||||
expect(msg.status).toBe('awaitingUser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentChatStream — done executionId', () => {
|
||||
it('stamps executionId from done onto minted messages', async () => {
|
||||
const events: AgentSseEvent[] = [
|
||||
{ type: 'text-start', id: 't1' },
|
||||
{ type: 'text-delta', id: 't1', delta: 'Hello' },
|
||||
{ type: 'text-end', id: 't1' },
|
||||
{ type: 'done', sessionId: 'thread-1', executionId: 'exec-live-1' },
|
||||
];
|
||||
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
|
||||
|
||||
const hook = buildHook();
|
||||
await hook.sendMessage('hi');
|
||||
|
||||
const assistant = hook.messages.value.find((m) => m.role === 'assistant');
|
||||
expect(assistant?.content).toBe('Hello');
|
||||
expect(assistant?.executionId).toBe('exec-live-1');
|
||||
});
|
||||
});
|
||||
|
||||
+12
-1
@@ -34,7 +34,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
resume: [payload: { runId: string; toolCallId: string; resumeData: unknown }];
|
||||
sendToAssistant: [];
|
||||
sendToAssistant: [executionId?: string];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
@@ -42,6 +42,11 @@ const canSendToAssistant = computed(() =>
|
||||
Boolean(props.canSendToAssistant && props.agentId && props.sessionId),
|
||||
);
|
||||
|
||||
function onFixWithAssistant(group: DisplayGroup) {
|
||||
const executionId = group.kind === 'toolRun' ? group.executionId : group.message.executionId;
|
||||
emit('sendToAssistant', executionId);
|
||||
}
|
||||
|
||||
function onInteractiveSubmit(payload: InteractivePayload, resumeData: unknown) {
|
||||
// Cards without a runId are disabled at the card level (see InteractiveCard).
|
||||
// This guard is a defensive belt-and-braces for the type narrowing.
|
||||
@@ -421,6 +426,9 @@ onBeforeUnmount(() => {
|
||||
v-if="group.toolCalls.length"
|
||||
:tool-calls="group.toolCalls"
|
||||
:project-id="projectId"
|
||||
:can-fix-with-assistant="canSendToAssistant"
|
||||
:execution-id="group.executionId"
|
||||
@fix-with-assistant="onFixWithAssistant(group)"
|
||||
/>
|
||||
<template v-for="tc in group.toolCalls" :key="`wait-${tc.toolCallId}`">
|
||||
<N8nText
|
||||
@@ -502,6 +510,9 @@ onBeforeUnmount(() => {
|
||||
v-if="group.message.toolCalls?.length"
|
||||
:tool-calls="group.message.toolCalls"
|
||||
:project-id="projectId"
|
||||
:can-fix-with-assistant="canSendToAssistant"
|
||||
:execution-id="group.message.executionId"
|
||||
@fix-with-assistant="onFixWithAssistant(group)"
|
||||
/>
|
||||
<template v-for="tc in group.message.toolCalls ?? []" :key="`wait-${tc.toolCallId}`">
|
||||
<N8nText
|
||||
|
||||
@@ -45,7 +45,7 @@ const emit = defineEmits<{
|
||||
'initial-consumed': [];
|
||||
back: [];
|
||||
'open-build': [];
|
||||
'send-to-assistant': [];
|
||||
'send-to-assistant': [executionId?: string];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
@@ -221,7 +221,7 @@ onBeforeUnmount(() => {
|
||||
:session-id="continueSessionId"
|
||||
:can-send-to-assistant="canSendToAssistant"
|
||||
@resume="resume"
|
||||
@send-to-assistant="emit('send-to-assistant')"
|
||||
@send-to-assistant="emit('send-to-assistant', $event)"
|
||||
/>
|
||||
|
||||
<div :class="$style.inputArea">
|
||||
|
||||
+157
-76
@@ -1,7 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
/** Shared Fix CTA used in both grouped and ungrouped tool-step layouts. */
|
||||
const FixWithAssistantCallout = defineComponent({
|
||||
name: 'FixWithAssistantCallout',
|
||||
props: {
|
||||
errorText: { type: String, required: true },
|
||||
},
|
||||
emits: ['fix'],
|
||||
setup(calloutProps, { emit: calloutEmit }) {
|
||||
const calloutI18n = useI18n();
|
||||
return () =>
|
||||
h(
|
||||
N8nCallout,
|
||||
{
|
||||
theme: 'danger',
|
||||
'data-test-id': 'agent-chat-tool-fix-with-assistant-callout',
|
||||
},
|
||||
{
|
||||
default: () => calloutProps.errorText,
|
||||
trailingContent: () =>
|
||||
h(
|
||||
N8nButton,
|
||||
{
|
||||
size: 'small',
|
||||
variant: 'subtle',
|
||||
'data-test-id': 'agent-chat-tool-fix-with-assistant',
|
||||
onClick: () => calloutEmit('fix'),
|
||||
},
|
||||
{
|
||||
icon: () => h(N8nIcon, { icon: 'sparkles', size: 'small' }),
|
||||
default: () => calloutI18n.baseText('agents.builder.preview.fixWithAssistant'),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: { FixWithAssistantCallout },
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { N8nAiActivityStep, N8nAiActivityStepGroup, N8nMarkdownEditor } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { toRef } from 'vue';
|
||||
import { computed, toRef } from 'vue';
|
||||
import type { ToolCall } from '@/features/ai/shared/agentsChat/types';
|
||||
import { useSubAgentNames } from '../composables/useSubAgentNames';
|
||||
import { formatToolNameForDisplay, getToolNameTranslationKey } from '../utils/toolDisplayName';
|
||||
@@ -23,10 +69,22 @@ import { TOOL_CALL_STATE } from '../constants';
|
||||
const props = defineProps<{
|
||||
toolCalls: ToolCall[];
|
||||
projectId?: string;
|
||||
canFixWithAssistant?: boolean;
|
||||
executionId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
fixWithAssistant: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const showFix = computed(() => Boolean(props.canFixWithAssistant && props.executionId));
|
||||
|
||||
const fixableErroredTools = computed(() =>
|
||||
showFix.value ? props.toolCalls.filter((tc) => tc.state === TOOL_CALL_STATE.ERROR) : [],
|
||||
);
|
||||
|
||||
function toolCallsNeedSubAgentNames(toolCalls: ToolCall[]): boolean {
|
||||
return toolCalls.some((tc) => {
|
||||
if (isDelegateSubAgentTool(tc.tool)) return true;
|
||||
@@ -85,6 +143,15 @@ function formatToolData(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2) ?? String(value);
|
||||
}
|
||||
|
||||
function isEmptyToolErrorPayload(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
if (typeof value === 'string') return value.trim().length === 0;
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toolStepView(tc: ToolCall): ToolStepDisplay {
|
||||
const details = getToolCallDetails(tc, i18n, subAgentNameById.value) ?? '';
|
||||
const metadata = toolStepMetadata(tc);
|
||||
@@ -97,7 +164,11 @@ function toolStepView(tc: ToolCall): ToolStepDisplay {
|
||||
}
|
||||
|
||||
function toolStepError(tc: ToolCall): string | undefined {
|
||||
return tc.state === 'error' ? formatToolData(tc.output) : undefined;
|
||||
if (tc.state !== TOOL_CALL_STATE.ERROR) return undefined;
|
||||
if (isEmptyToolErrorPayload(tc.output)) {
|
||||
return i18n.baseText('agents.chat.toolError.generic');
|
||||
}
|
||||
return formatToolData(tc.output);
|
||||
}
|
||||
|
||||
function isToolStepLoading(tc: ToolCall): boolean {
|
||||
@@ -122,83 +193,93 @@ function hasLoadingToolCall(): boolean {
|
||||
|
||||
<template>
|
||||
<div :class="$style.toolSteps">
|
||||
<N8nAiActivityStepGroup
|
||||
v-if="toolCalls.length > 1"
|
||||
:label="groupLabel()"
|
||||
size="small"
|
||||
:loading="hasLoadingToolCall()"
|
||||
>
|
||||
<template v-for="tc in toolCalls" :key="tc.toolCallId">
|
||||
<N8nAiActivityStep
|
||||
v-for="view in [toolStepView(tc)]"
|
||||
:key="`${tc.toolCallId}-${view.label}`"
|
||||
:label="view.label"
|
||||
:loading="isToolStepLoading(tc)"
|
||||
:error="toolStepError(tc)"
|
||||
:has-content="view.expandable"
|
||||
>
|
||||
<N8nMarkdownEditor
|
||||
v-if="view.details"
|
||||
:model-value="view.details"
|
||||
readonly
|
||||
variant="ghost"
|
||||
show-toolbar="never"
|
||||
max-height="240px"
|
||||
:class="$style.answer"
|
||||
/>
|
||||
<div v-else-if="view.hasRawData" :class="$style.toolDataList">
|
||||
<div v-if="tc.input !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.input') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
|
||||
<template v-if="toolCalls.length > 1">
|
||||
<N8nAiActivityStepGroup :label="groupLabel()" size="small" :loading="hasLoadingToolCall()">
|
||||
<template v-for="tc in toolCalls" :key="tc.toolCallId">
|
||||
<N8nAiActivityStep
|
||||
v-for="view in [toolStepView(tc)]"
|
||||
:key="`${tc.toolCallId}-${view.label}`"
|
||||
:label="view.label"
|
||||
:loading="isToolStepLoading(tc)"
|
||||
:error="toolStepError(tc)"
|
||||
:hide-error-callout="showFix && tc.state === TOOL_CALL_STATE.ERROR"
|
||||
:has-content="view.expandable"
|
||||
>
|
||||
<N8nMarkdownEditor
|
||||
v-if="view.details"
|
||||
:model-value="view.details"
|
||||
readonly
|
||||
variant="ghost"
|
||||
show-toolbar="never"
|
||||
max-height="240px"
|
||||
:class="$style.answer"
|
||||
/>
|
||||
<div v-else-if="view.hasRawData" :class="$style.toolDataList">
|
||||
<div v-if="tc.input !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.input') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
|
||||
</div>
|
||||
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.output') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.output) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.output') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.output) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</N8nAiActivityStep>
|
||||
</template>
|
||||
</N8nAiActivityStepGroup>
|
||||
</N8nAiActivityStep>
|
||||
</template>
|
||||
</N8nAiActivityStepGroup>
|
||||
<FixWithAssistantCallout
|
||||
v-for="tc in fixableErroredTools"
|
||||
:key="`fix-${tc.toolCallId}`"
|
||||
:error-text="toolStepError(tc) ?? ''"
|
||||
@fix="emit('fixWithAssistant')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<N8nAiActivityStep
|
||||
v-for="tc in toolCalls"
|
||||
:key="tc.toolCallId"
|
||||
:label="toolStepView(tc).label"
|
||||
:loading="isToolStepLoading(tc)"
|
||||
:error="toolStepError(tc)"
|
||||
:has-content="toolStepView(tc).expandable"
|
||||
>
|
||||
<template v-for="view in [toolStepView(tc)]" :key="view.label">
|
||||
<N8nMarkdownEditor
|
||||
v-if="view.details"
|
||||
:model-value="view.details"
|
||||
readonly
|
||||
variant="ghost"
|
||||
show-toolbar="never"
|
||||
max-height="240px"
|
||||
:class="$style.answer"
|
||||
/>
|
||||
<div v-else-if="view.hasRawData" :class="$style.toolDataList">
|
||||
<div v-if="tc.input !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.input') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
|
||||
<template v-for="tc in toolCalls" :key="tc.toolCallId">
|
||||
<N8nAiActivityStep
|
||||
:label="toolStepView(tc).label"
|
||||
:loading="isToolStepLoading(tc)"
|
||||
:error="toolStepError(tc)"
|
||||
:hide-error-callout="showFix && tc.state === TOOL_CALL_STATE.ERROR"
|
||||
:has-content="toolStepView(tc).expandable"
|
||||
>
|
||||
<template v-for="view in [toolStepView(tc)]" :key="view.label">
|
||||
<N8nMarkdownEditor
|
||||
v-if="view.details"
|
||||
:model-value="view.details"
|
||||
readonly
|
||||
variant="ghost"
|
||||
show-toolbar="never"
|
||||
max-height="240px"
|
||||
:class="$style.answer"
|
||||
/>
|
||||
<div v-else-if="view.hasRawData" :class="$style.toolDataList">
|
||||
<div v-if="tc.input !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.input') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
|
||||
</div>
|
||||
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.output') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.output) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
|
||||
<span :class="$style.toolDataLabel">
|
||||
{{ i18n.baseText('agentSessions.timeline.output') }}
|
||||
</span>
|
||||
<pre :class="$style.toolDataContent">{{ formatToolData(tc.output) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nAiActivityStep>
|
||||
</template>
|
||||
</N8nAiActivityStep>
|
||||
<FixWithAssistantCallout
|
||||
v-if="showFix && tc.state === TOOL_CALL_STATE.ERROR"
|
||||
:error-text="toolStepError(tc) ?? ''"
|
||||
@fix="emit('fixWithAssistant')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'continue-loaded': [count: number];
|
||||
'open-build': [];
|
||||
'send-to-assistant': [];
|
||||
'send-to-assistant': [executionId?: string];
|
||||
'open-memory-settings': [];
|
||||
}>();
|
||||
|
||||
@@ -60,7 +60,7 @@ const showEpisodicMemoryCallout = computed(
|
||||
:can-send-to-assistant="canSendToAssistant"
|
||||
@continue-loaded="emit('continue-loaded', $event)"
|
||||
@open-build="emit('open-build')"
|
||||
@send-to-assistant="emit('send-to-assistant')"
|
||||
@send-to-assistant="emit('send-to-assistant', $event)"
|
||||
>
|
||||
<template #above-input>
|
||||
<N8nCallout
|
||||
|
||||
@@ -410,6 +410,11 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
||||
break;
|
||||
}
|
||||
case 'done':
|
||||
if (event.executionId) {
|
||||
for (const msg of session.minted) {
|
||||
msg.executionId = event.executionId;
|
||||
}
|
||||
}
|
||||
return { done: true };
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -137,7 +137,7 @@ const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissio
|
||||
|
||||
const isVersionHistoryOpen = ref(false);
|
||||
|
||||
async function onSendPreviewToAssistant() {
|
||||
async function onSendPreviewToAssistant(executionId?: string) {
|
||||
const threadId = effectiveSessionId.value;
|
||||
if (!threadId || !agentId.value || !projectId.value) return;
|
||||
|
||||
@@ -148,6 +148,7 @@ async function onSendPreviewToAssistant() {
|
||||
agentName: agentName.value || undefined,
|
||||
agentIcon: localConfig.value?.personalisation?.icon,
|
||||
sessionTitle: currentSessionTitle.value || undefined,
|
||||
executionId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -17,13 +17,16 @@ vi.mock('../composables/useInstanceAiHandoff', () => ({
|
||||
buildInstanceAiAgentPreviewHandoffContext: ({
|
||||
agentId,
|
||||
threadId,
|
||||
executionId,
|
||||
}: {
|
||||
agentId: string;
|
||||
threadId: string;
|
||||
executionId?: string;
|
||||
}) => ({
|
||||
source: 'agent-preview',
|
||||
agentId,
|
||||
threadId,
|
||||
...(executionId ? { executionId } : {}),
|
||||
}),
|
||||
useInstanceAiHandoff: () => ({ openThreadWithContext: openThreadWithContextMock }),
|
||||
}));
|
||||
@@ -66,6 +69,41 @@ describe('useInstanceAiAgentPreviewHandoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes executionId into preview handoff context and telemetry', async () => {
|
||||
openThreadWithContextMock.mockResolvedValue(true);
|
||||
const { useInstanceAiAgentPreviewHandoff } = await import(
|
||||
'../composables/useInstanceAiAgentPreviewHandoff'
|
||||
);
|
||||
|
||||
await useInstanceAiAgentPreviewHandoff().sendPreviewSessionToInstanceAi({
|
||||
projectId: 'project-1',
|
||||
agentId: 'agent-1',
|
||||
threadId: 'thread-1',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
|
||||
expect(openThreadWithContextMock).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
{
|
||||
source: 'agent-preview',
|
||||
agentId: 'agent-1',
|
||||
threadId: 'thread-1',
|
||||
executionId: 'exec-1',
|
||||
},
|
||||
{
|
||||
source: 'agent_preview',
|
||||
origin: 'internal',
|
||||
sourceContext: { agentId: 'agent-1', previewThreadId: 'thread-1' },
|
||||
},
|
||||
{ newTab: true },
|
||||
);
|
||||
expect(trackMock).toHaveBeenCalledWith('Instance AI opened from agent preview', {
|
||||
agent_id: 'agent-1',
|
||||
preview_thread_id: 'thread-1',
|
||||
preview_execution_id: 'exec-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not track telemetry when opening the instance AI thread fails', async () => {
|
||||
openThreadWithContextMock.mockResolvedValue(false);
|
||||
const { useInstanceAiAgentPreviewHandoff } = await import(
|
||||
|
||||
+15
@@ -89,6 +89,21 @@ describe('useInstanceAiHandoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards executionId when provided', () => {
|
||||
expect(
|
||||
buildInstanceAiAgentPreviewHandoffContext({
|
||||
agentId: 'agent-1',
|
||||
threadId: 'thread-1',
|
||||
executionId: 'exec-1',
|
||||
}),
|
||||
).toEqual({
|
||||
source: 'agent-preview',
|
||||
agentId: 'agent-1',
|
||||
threadId: 'thread-1',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards agentName, agentIcon and sessionTitle when provided', () => {
|
||||
expect(
|
||||
buildInstanceAiAgentPreviewHandoffContext({
|
||||
|
||||
+4
@@ -13,6 +13,7 @@ interface AgentPreviewHandoffParams {
|
||||
agentName?: string;
|
||||
agentIcon?: string;
|
||||
sessionTitle?: string;
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
export function useInstanceAiAgentPreviewHandoff() {
|
||||
@@ -27,6 +28,7 @@ export function useInstanceAiAgentPreviewHandoff() {
|
||||
agentName,
|
||||
agentIcon,
|
||||
sessionTitle,
|
||||
executionId,
|
||||
}: AgentPreviewHandoffParams): Promise<void> {
|
||||
if (!canSendPreviewToInstanceAi.value || !projectId || !agentId || !threadId) return;
|
||||
|
||||
@@ -38,6 +40,7 @@ export function useInstanceAiAgentPreviewHandoff() {
|
||||
agentName,
|
||||
agentIcon,
|
||||
sessionTitle,
|
||||
executionId,
|
||||
}),
|
||||
{
|
||||
source: 'agent_preview',
|
||||
@@ -51,6 +54,7 @@ export function useInstanceAiAgentPreviewHandoff() {
|
||||
telemetry.track('Instance AI opened from agent preview', {
|
||||
agent_id: agentId,
|
||||
preview_thread_id: threadId,
|
||||
...(executionId ? { preview_execution_id: executionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -81,6 +81,7 @@ export function buildInstanceAiAgentPreviewHandoffContext(params: {
|
||||
agentName?: string;
|
||||
agentIcon?: string;
|
||||
sessionTitle?: string;
|
||||
executionId?: string;
|
||||
}): InstanceAiHandoffContext {
|
||||
return {
|
||||
source: 'agent-preview',
|
||||
@@ -89,6 +90,7 @@ export function buildInstanceAiAgentPreviewHandoffContext(params: {
|
||||
...(params.agentName ? { agentName: params.agentName } : {}),
|
||||
...(params.agentIcon ? { agentIcon: params.agentIcon } : {}),
|
||||
...(params.sessionTitle ? { sessionTitle: params.sessionTitle } : {}),
|
||||
...(params.executionId ? { executionId: params.executionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+107
@@ -32,6 +32,113 @@ describe('shared agents chat display groups', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps executionId on folded toolRun groups', () => {
|
||||
const messages: AgentsChatMessage[] = [
|
||||
{ id: 'u1', role: 'user', content: 'start', executionId: 'exec-1' },
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-1',
|
||||
toolCalls: [{ tool: 'search', toolCallId: 'tc1', state: 'done' }],
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-1',
|
||||
toolCalls: [{ tool: 'write', toolCallId: 'tc2', state: 'error' }],
|
||||
},
|
||||
{ id: 'a3', role: 'assistant', content: 'finished', executionId: 'exec-1' },
|
||||
];
|
||||
|
||||
const groups = buildDisplayGroups(messages);
|
||||
expect(groups[1].kind).toBe('toolRun');
|
||||
if (groups[1].kind === 'toolRun') {
|
||||
expect(groups[1].executionId).toBe('exec-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fold tool-only assistants with different executionIds (HITL resume history)', () => {
|
||||
// Suspended turn has no user message on the resume execution, so history
|
||||
// is user → tool-only assistant (exec-1) → tool-only + final (exec-2).
|
||||
// Folding those would keep exec-1 via ??= while the error lives on exec-2.
|
||||
const messages: AgentsChatMessage[] = [
|
||||
{ id: 'exec-1:user', role: 'user', content: 'do it', executionId: 'exec-1' },
|
||||
{
|
||||
id: 'exec-1:assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-1',
|
||||
toolCalls: [
|
||||
{ tool: 'search', toolCallId: 'tc1', state: 'done' },
|
||||
{ tool: 'approval', toolCallId: 'tc-hitl', state: 'done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'exec-2:assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-2',
|
||||
toolCalls: [{ tool: 'write_file', toolCallId: 'tc2', state: 'error' }],
|
||||
},
|
||||
{
|
||||
id: 'exec-2:final',
|
||||
role: 'assistant',
|
||||
content: 'failed to write',
|
||||
executionId: 'exec-2',
|
||||
},
|
||||
];
|
||||
|
||||
const groups = buildDisplayGroups(messages);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups[0].kind).toBe('message');
|
||||
expect(groups[1].kind).toBe('toolRun');
|
||||
expect(groups[2].kind).toBe('toolRun');
|
||||
if (groups[1].kind === 'toolRun' && groups[2].kind === 'toolRun') {
|
||||
expect(groups[1].executionId).toBe('exec-1');
|
||||
expect(groups[1].toolCalls.map((tc) => tc.toolCallId)).toEqual(['tc1', 'tc-hitl']);
|
||||
expect(groups[1].finalMessage).toBeUndefined();
|
||||
expect(groups[2].executionId).toBe('exec-2');
|
||||
expect(groups[2].toolCalls.map((tc) => tc.toolCallId)).toEqual(['tc2']);
|
||||
expect(groups[2].finalMessage?.id).toBe('exec-2:final');
|
||||
}
|
||||
});
|
||||
|
||||
it('still folds when earlier messages lack executionId (live stream before done)', () => {
|
||||
const messages: AgentsChatMessage[] = [
|
||||
{ id: 'u1', role: 'user', content: 'do it' },
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{ tool: 'search', toolCallId: 'tc1', state: 'done' },
|
||||
{ tool: 'approval', toolCallId: 'tc-hitl', state: 'done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
executionId: 'exec-2',
|
||||
toolCalls: [{ tool: 'write_file', toolCallId: 'tc2', state: 'error' }],
|
||||
},
|
||||
{ id: 'a3', role: 'assistant', content: 'failed', executionId: 'exec-2' },
|
||||
];
|
||||
|
||||
const groups = buildDisplayGroups(messages);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[1].kind).toBe('toolRun');
|
||||
if (groups[1].kind === 'toolRun') {
|
||||
expect(groups[1].executionId).toBe('exec-2');
|
||||
expect(groups[1].toolCalls.map((tc) => tc.toolCallId)).toEqual(['tc1', 'tc-hitl', 'tc2']);
|
||||
expect(groups[1].finalMessage?.id).toBe('a3');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not group assistant messages with visible text as tool-only messages', () => {
|
||||
expect(
|
||||
isGroupable({
|
||||
|
||||
@@ -29,12 +29,38 @@ export type DisplayGroup =
|
||||
* (thinking → tools → interactives → final text).
|
||||
*/
|
||||
finalMessage?: AgentsChatMessage;
|
||||
/**
|
||||
* Turn execution id from the first folded message that has one.
|
||||
* Messages with a different defined executionId are never folded in
|
||||
* (HITL resume history must not inherit the suspended turn's id).
|
||||
*/
|
||||
executionId?: string;
|
||||
};
|
||||
|
||||
export function isGroupable(message: AgentsChatMessage): boolean {
|
||||
return message.role === 'assistant' && !!message.toolCalls?.length && !message.content.trim();
|
||||
}
|
||||
|
||||
type ToolRunGroup = Extract<DisplayGroup, { kind: 'toolRun' }>;
|
||||
|
||||
/**
|
||||
* Whether `message` may join an open toolRun. Same-turn live streams often
|
||||
* lack executionId until `done`; those still fold. Distinct defined ids
|
||||
* (suspended vs resumed HITL executions) must stay separate so Fix CTA
|
||||
* handoff uses the turn that owns the errored tool.
|
||||
*/
|
||||
function canAppendToToolRun(last: ToolRunGroup, message: AgentsChatMessage): boolean {
|
||||
if (last.finalMessage) return false;
|
||||
if (
|
||||
last.executionId !== undefined &&
|
||||
message.executionId !== undefined &&
|
||||
last.executionId !== message.executionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two records of the same tool call: messages are now stored both when a
|
||||
* stream suspends and again on completion, so history can carry the same
|
||||
@@ -102,7 +128,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
|
||||
for (const message of messages) {
|
||||
if (isGroupable(message)) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.kind === 'toolRun' && !last.finalMessage) {
|
||||
if (last?.kind === 'toolRun' && canAppendToToolRun(last, message)) {
|
||||
last.toolCalls = appendToolCalls(last.toolCalls, message.toolCalls ?? []);
|
||||
if (message.thinking) {
|
||||
last.thinking = last.thinking
|
||||
@@ -113,6 +139,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
|
||||
last.interactives,
|
||||
getMessageInteractives(message),
|
||||
);
|
||||
last.executionId ??= message.executionId;
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
@@ -121,14 +148,16 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
|
||||
thinking: message.thinking ?? '',
|
||||
toolCalls: [...(message.toolCalls ?? [])],
|
||||
interactives: getMessageInteractives(message),
|
||||
...(message.executionId ? { executionId: message.executionId } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.kind === 'toolRun' && !last.finalMessage) {
|
||||
if (last?.kind === 'toolRun' && canAppendToToolRun(last, message)) {
|
||||
last.finalMessage = message;
|
||||
last.executionId ??= message.executionId;
|
||||
if (message.thinking) {
|
||||
last.thinking = last.thinking
|
||||
? `${last.thinking}\n\n${message.thinking}`
|
||||
|
||||
@@ -237,6 +237,7 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
|
||||
thinking: thinking || undefined,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
...(status && { status }),
|
||||
...(msg.executionId ? { executionId: msg.executionId } : {}),
|
||||
};
|
||||
setMessageInteractives(chatMessage, interactives);
|
||||
result.push(chatMessage);
|
||||
|
||||
@@ -91,6 +91,8 @@ export interface AgentsChatMessage {
|
||||
status?: ChatMessageStatus;
|
||||
interactives?: InteractivePayload[];
|
||||
interactive?: InteractivePayload;
|
||||
/** Persisted agent execution id for this turn (history parse or live SSE `done`). */
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
export type ChatMessage = AgentsChatMessage;
|
||||
|
||||
Reference in New Issue
Block a user