refactor(editor): Remove composerResumeData from the Agents chat host API (no-changelog)

The interaction extensions now own this: an extension can answer its open
card from the composer text. On the panel, the host function ran for every
open card, built-in cards included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Jaakko Husso
2026-10-10 00:29:41 +03:00
co-authored by Claude Opus 5.5
parent 71fd3c55c3
commit 8a5703dc88
2 changed files with 2 additions and 120 deletions
@@ -11,7 +11,7 @@ import type {
ProviderAttachmentCapabilities,
} from '@n8n/api-types';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type { ChatMessage, InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import type { ChatMessage } from '@/features/ai/shared/agentsChat/types';
import AgentChatPanel from '../components/AgentChatPanel.vue';
import AgentPreviewDock from '../components/AgentPreviewDock.vue';
import AgentPreviewChatPage from '../components/AgentPreviewChatPage.vue';
@@ -375,7 +375,6 @@ describe('AgentChatPanel', () => {
clientContext: () => Record<string, unknown> | undefined;
placeholder: string;
showAttachButton: boolean;
composerResumeData: (payload: InteractivePayload, text: string) => unknown;
}> = {},
attachTo?: HTMLElement,
slots?: Record<string, string>,
@@ -3323,87 +3322,6 @@ describe('AgentChatPanel', () => {
expect(wrapper.find('[data-test-id="chat-input-attach-button"]').exists()).toBe(false);
});
it('answers the open card with the resume data from composerResumeData', async () => {
messagesMock.value = [openInteractiveMessage()];
resumeMock.mockResolvedValueOnce('sent');
const composerResumeData = vi.fn((_payload: InteractivePayload, text: string) => ({
approved: false,
feedback: text,
}));
const wrapper = mountPanel({ composerResumeData });
const input = composer(wrapper);
input.vm.$emit('update:modelValue', 'use Teams instead');
input.vm.$emit('submit');
await flushPromises();
expect(composerResumeData).toHaveBeenCalledWith(
expect.objectContaining({ toolCallId: 'tc-1', runId: 'run-1' }),
'use Teams instead',
);
expect(resumeMock).toHaveBeenCalledWith(
{
runId: 'run-1',
toolCallId: 'tc-1',
resumeData: { approved: false, feedback: 'use Teams instead' },
},
expect.any(Function),
);
expect(cancelAndSteerMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
resumeMock.mock.lastCall?.[1]?.();
await nextTick();
expect(input.props('modelValue')).toBe('');
});
it('keeps cancel-and-steer when composerResumeData returns undefined', async () => {
messagesMock.value = [openInteractiveMessage()];
const composerResumeData = vi.fn(() => undefined);
const wrapper = mountPanel({ composerResumeData });
hostVm(wrapper).sendMessageFromOutside('go another direction');
await flushPromises();
expect(composerResumeData).toHaveBeenCalledOnce();
expect(resumeMock).not.toHaveBeenCalled();
expect(cancelAndSteerMock).toHaveBeenCalledWith(
'go another direction',
expect.any(Function),
);
});
it('does not answer a waiting card with composerResumeData', async () => {
messagesMock.value = [openWaitMessage()];
sendMessageMock.mockResolvedValueOnce('sent');
const composerResumeData = vi.fn(() => ({ approved: false }));
const wrapper = mountPanel({ composerResumeData });
hostVm(wrapper).sendMessageFromOutside('still there?');
await flushPromises();
expect(composerResumeData).not.toHaveBeenCalled();
expect(resumeMock).not.toHaveBeenCalled();
expect(sendMessageMock).toHaveBeenCalledOnce();
});
it('keeps a busy card answer as a draft and does not retry it', async () => {
messagesMock.value = [openInteractiveMessage()];
isCancellingMock.value = true;
resumeMock.mockResolvedValueOnce('busy');
const composerResumeData = vi.fn(() => ({ approved: false }));
const wrapper = mountPanel({ composerResumeData });
hostVm(wrapper).sendMessageFromOutside('use Teams');
await flushPromises();
isCancellingMock.value = false;
await flushPromises();
expect(resumeMock).toHaveBeenCalledOnce();
expect(composer(wrapper).props('modelValue')).toBe('use Teams');
});
});
describe('message-accepted', () => {
@@ -53,11 +53,7 @@ import {
parseApprovalInput,
} from '@/features/ai/shared/agentsChat/messageMappers';
import AgentChatEmptyState from './AgentChatEmptyState.vue';
import type {
ChatMessage,
ChatMessageAttachment,
InteractivePayload,
} from '@/features/ai/shared/agentsChat/types';
import type { ChatMessage, ChatMessageAttachment } from '@/features/ai/shared/agentsChat/types';
import { resolveFileMimeType } from '@/app/utils/fileUtils';
import { isFileAcceptedByAccept } from '@/features/ai/shared/utils/fileAccept';
import AgentChatMessageList from './AgentChatMessageList.vue';
@@ -132,14 +128,6 @@ const props = withDefaults(
* the exposed `openFilePicker`.
*/
showAttachButton?: boolean;
/**
* Lets the host answer the open card on the last turn with the composer
* text, instead of the default cancel-and-steer. For example, a review
* card where typed text means "request changes" can return
* `{ approved: false, feedback: text }`. Return `undefined` to keep the
* default behavior. Staged files stay in the composer.
*/
composerResumeData?: (payload: InteractivePayload, text: string) => unknown;
}>(),
{
visible: true,
@@ -158,7 +146,6 @@ const props = withDefaults(
clientContext: undefined,
placeholder: undefined,
showAttachButton: true,
composerResumeData: undefined,
},
);
@@ -1086,29 +1073,6 @@ async function submitDraft(text: string, files: File[]): Promise<SubmitResult> {
props.agentId === target.agentId &&
props.continueSessionId === target.continueSessionId;
// A waiting card resumes only from the workflow, so the host cannot answer it with text.
const tailInteractive = hasOpenWaitCard.value ? undefined : openInteractive.value;
const composerResume =
text && tailInteractive?.runId && props.composerResumeData
? props.composerResumeData(tailInteractive, text)
: undefined;
if (tailInteractive?.runId && composerResume !== undefined) {
const result = await resume(
{
runId: tailInteractive.runId,
toolCallId: tailInteractive.toolCallId,
resumeData: composerResume,
},
() => {
if (!isCurrentTarget()) return;
if (inputText.value.trim() === text) inputText.value = '';
consumeQueuedExternalMessage(text);
trackSentToN8nChat(hadNoMessagesBeforeSend);
},
);
return result === 'busy' ? 'busy' : 'sent';
}
if (hasOpenInteractiveQuestion.value) {
if (!text) return 'rejected';
const result = await cancelAndSteer(text, () => {