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:
Anne Aguirre
2026-07-23 12:31:07 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent b93a99945b
commit 96268910d3
30 changed files with 841 additions and 123 deletions
+1 -1
View File
@@ -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',
});
});
});
@@ -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,
});
}
@@ -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' },
@@ -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([
@@ -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', () => {
@@ -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');
});
});
@@ -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">
@@ -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,
});
}
@@ -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(
@@ -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({
@@ -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 } : {}),
});
}
@@ -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 } : {}),
};
}
@@ -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;