mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
71fd3c55c3
commit
8a5703dc88
@@ -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, () => {
|
||||
|
||||
Reference in New Issue
Block a user