mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
feat(core): Send client context with Agents chat messages (no-changelog)
`AgentChatMessageDto` accepts an optional `clientContext` object. The editor chat stream sends it only when it is set. `AgentChatPanel` gets a `clientContext` prop that builds it for each new message, after `beforeSend`. Project agent routes do not read it, so their behavior does not change. Export `ForwardedChildChunk` from `@n8n/agents`. Hosts that forward sub-agent progress with `AgentEvent.SubAgentChunk` can type the chunk with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a82909e13
commit
cfb7b8f287
|
|
@ -119,7 +119,7 @@ export type {
|
|||
export { FINISH_REASONS, isFinishReason } from './types';
|
||||
export type { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
export { AgentEvent } from './types';
|
||||
export type { AgentEventData, AgentEventHandler } from './types';
|
||||
export type { AgentEventData, AgentEventHandler, ForwardedChildChunk } from './types';
|
||||
export {
|
||||
OBSERVATION_LOG_MARKERS,
|
||||
OBSERVATION_LOG_STATUSES,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,30 @@ describe('AgentChatMessageDto', () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an object client context and keeps it in the parsed payload', () => {
|
||||
const clientContext = { timeZone: 'Europe/Helsinki', nested: { ids: ['a', 'b'] }, flag: null };
|
||||
const payload = { message: 'hello', clientContext };
|
||||
expect(AgentChatMessageDto.parse(payload)).toEqual(payload);
|
||||
expect(AgentChatMessageDto.safeParse({ message: 'hello', clientContext: {} }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a payload without a client context', () => {
|
||||
const parsed = AgentChatMessageDto.parse({ message: 'hello' });
|
||||
expect(parsed).not.toHaveProperty('clientContext');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['an array', [{ timeZone: 'UTC' }]],
|
||||
['a string', 'UTC'],
|
||||
['a number', 1],
|
||||
['a boolean', true],
|
||||
])('rejects a client context that is %s', (_label, clientContext) => {
|
||||
expect(AgentChatMessageDto.safeParse({ message: 'hello', clientContext }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects attachments with empty fileName or mimeType', () => {
|
||||
expect(
|
||||
AgentChatMessageDto.safeParse({
|
||||
|
|
|
|||
|
|
@ -257,6 +257,12 @@ const agentChatMessageShape = {
|
|||
.array(agentChatAttachmentSchema)
|
||||
.max(MAX_AGENT_CHAT_ATTACHMENTS_PER_MESSAGE)
|
||||
.optional(),
|
||||
/**
|
||||
* Per-message context from the client (for example the user's time zone).
|
||||
* The Agents layer does not read it. A system agent's provider defines its
|
||||
* shape and validates it. Project agents ignore it.
|
||||
*/
|
||||
clientContext: z.record(z.unknown()).optional(),
|
||||
};
|
||||
|
||||
const agentChatMessageSchema = z
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ describe('AgentChatPanel', () => {
|
|||
channel: 'chat' | 'n8n-chat';
|
||||
centerEmptyState: boolean;
|
||||
newSession: boolean;
|
||||
clientContext: () => Record<string, unknown> | undefined;
|
||||
}> = {},
|
||||
attachTo?: HTMLElement,
|
||||
) {
|
||||
|
|
@ -419,6 +420,7 @@ describe('AgentChatPanel', () => {
|
|||
'Find the invoice',
|
||||
[file],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(messagesMock.value.at(-1)?.status).toBe('error');
|
||||
wrapper.unmount();
|
||||
|
|
@ -454,6 +456,7 @@ describe('AgentChatPanel', () => {
|
|||
'Find the invoice',
|
||||
withFile ? [file] : undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
},
|
||||
|
|
@ -891,7 +894,12 @@ describe('AgentChatPanel', () => {
|
|||
input.vm.$emit('submit');
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('new message', [userFile], expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'new message',
|
||||
[userFile],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -1064,7 +1072,12 @@ describe('AgentChatPanel', () => {
|
|||
composer.vm.$emit('update:modelValue', 'updated');
|
||||
composer.vm.$emit('submit');
|
||||
await flushPromises();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('updated', undefined, expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'updated',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -1220,6 +1233,7 @@ describe('AgentChatPanel', () => {
|
|||
'original',
|
||||
[expect.any(File)],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
const files = sendMessageMock.mock.calls[0][1];
|
||||
expect(files[0].name).toBe('notes.txt');
|
||||
|
|
@ -2041,10 +2055,62 @@ describe('AgentChatPanel', () => {
|
|||
resolveBeforeSend();
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('update config', undefined, expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'update config',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(events).toEqual(['beforeSend', 'sendMessage']);
|
||||
});
|
||||
|
||||
describe('clientContext', () => {
|
||||
const send = (wrapper: ReturnType<typeof mountPanel>, text: string) =>
|
||||
(
|
||||
wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void }
|
||||
).sendMessageFromOutside(text);
|
||||
|
||||
it('builds the client context after beforeSend and sends it with the message', async () => {
|
||||
const events: string[] = [];
|
||||
const beforeSend = vi.fn(() => {
|
||||
events.push('beforeSend');
|
||||
});
|
||||
const clientContext = vi.fn(() => {
|
||||
events.push('clientContext');
|
||||
return { timeZone: 'Europe/Helsinki' };
|
||||
});
|
||||
sendMessageMock.mockResolvedValue('sent');
|
||||
const wrapper = mountPanel({ beforeSend, clientContext });
|
||||
|
||||
send(wrapper, 'hello');
|
||||
await flushPromises();
|
||||
|
||||
expect(events).toEqual(['beforeSend', 'clientContext']);
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('hello', undefined, expect.any(Function), {
|
||||
timeZone: 'Europe/Helsinki',
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('builds a fresh client context for each message', async () => {
|
||||
let count = 0;
|
||||
const clientContext = vi.fn(() => ({ count: ++count }));
|
||||
sendMessageMock.mockResolvedValue('sent');
|
||||
const wrapper = mountPanel({ clientContext });
|
||||
|
||||
send(wrapper, 'first');
|
||||
await flushPromises();
|
||||
send(wrapper, 'second');
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock.mock.calls.map((call) => call[3])).toEqual([
|
||||
{ count: 1 },
|
||||
{ count: 2 },
|
||||
]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('retains an outside prompt during initial loading and consumes it only after acceptance', async () => {
|
||||
isLoadingHistoryMock.value = true;
|
||||
isStreamingMock.value = true;
|
||||
|
|
@ -2071,6 +2137,7 @@ describe('AgentChatPanel', () => {
|
|||
'Test this task',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
|
||||
expect(trackSubmittedMessageMock).not.toHaveBeenCalled();
|
||||
|
|
@ -2103,6 +2170,7 @@ describe('AgentChatPanel', () => {
|
|||
'Test this task',
|
||||
[file],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
|
@ -2118,7 +2186,12 @@ describe('AgentChatPanel', () => {
|
|||
).sendMessageFromOutside('', [file]);
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenCalledExactlyOnceWith('', [file], expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'',
|
||||
[file],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
|
|
@ -2143,6 +2216,7 @@ describe('AgentChatPanel', () => {
|
|||
'Test these instructions',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
sendMessageMock.mock.lastCall?.[2]?.();
|
||||
await nextTick();
|
||||
|
|
@ -2255,6 +2329,7 @@ describe('AgentChatPanel', () => {
|
|||
draft.trim(),
|
||||
[file],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
sendMessageMock.mock.lastCall?.[2]?.();
|
||||
await nextTick();
|
||||
|
|
@ -2278,6 +2353,7 @@ describe('AgentChatPanel', () => {
|
|||
'keep this draft',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
|
@ -2316,6 +2392,7 @@ describe('AgentChatPanel', () => {
|
|||
'edited draft',
|
||||
outcome === 'busy' ? [file, nextFile] : [nextFile],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
wrapper.unmount();
|
||||
},
|
||||
|
|
@ -2504,6 +2581,7 @@ describe('AgentChatPanel', () => {
|
|||
'Keep this draft',
|
||||
[file],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
|
|
@ -2851,7 +2929,12 @@ describe('AgentChatPanel', () => {
|
|||
).sendMessageFromOutside('any news?');
|
||||
await flushPromises();
|
||||
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('any news?', undefined, expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'any news?',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
expect(cancelAndSteerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -2912,7 +2995,12 @@ describe('AgentChatPanel', () => {
|
|||
await flushPromises();
|
||||
|
||||
expect(cancelAndSteerMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('something new', undefined, expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
'something new',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// An abandoned wait card earlier in the thread must not hide a real question
|
||||
|
|
@ -3001,7 +3089,7 @@ describe('AgentChatPanel', () => {
|
|||
expect(chatInput.props('canSubmit')).toBe(true);
|
||||
chatInput.vm.$emit('submit');
|
||||
await flushPromises();
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('', [file], expect.any(Function));
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('', [file], expect.any(Function), undefined);
|
||||
sendMessageMock.mock.lastCall?.[2]?.();
|
||||
await nextTick();
|
||||
expect(chatInput.props('showStopButton')).toBe(true);
|
||||
|
|
@ -3104,6 +3192,7 @@ describe('AgentChatPanel', () => {
|
|||
'',
|
||||
[supported],
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3170,6 +3259,7 @@ describe('AgentPreviewDock stream lifecycle', () => {
|
|||
'Message from the dock',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
wrapper.unmount();
|
||||
|
|
|
|||
|
|
@ -4895,3 +4895,55 @@ describe('useAgentChatStream — n8n Chat channel', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentChatStream — client context', () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
getTestChatMessagesMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockChatFetch() {
|
||||
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) =>
|
||||
makeSseResponse([{ type: 'done' }]),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
const sentBody = () => {
|
||||
const init = fetchMock.mock.calls[0]?.[1];
|
||||
return JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
};
|
||||
return { fetchMock, sentBody };
|
||||
}
|
||||
|
||||
it('sends the client context with the message when it is set', async () => {
|
||||
const { fetchMock, sentBody } = mockChatFetch();
|
||||
const hook = buildHook();
|
||||
|
||||
await hook.sendMessage('hello', undefined, undefined, { timeZone: 'Europe/Helsinki' });
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(sentBody()).toEqual({
|
||||
message: 'hello',
|
||||
clientContext: { timeZone: 'Europe/Helsinki' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the client context out of the request body when it is not set', async () => {
|
||||
const { fetchMock, sentBody } = mockChatFetch();
|
||||
const hook = buildHook();
|
||||
|
||||
await hook.sendMessage('hello');
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(sentBody()).toEqual({ message: 'hello' });
|
||||
expect(sentBody()).not.toHaveProperty('clientContext');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@ const props = withDefaults(
|
|||
* increase action. Resolves true once the new cap is saved.
|
||||
*/
|
||||
increaseBudget?: (payload: { field: BudgetAmountField; amount: number }) => Promise<boolean>;
|
||||
/**
|
||||
* Builds the client context that is sent with each new message (for
|
||||
* example the user's time zone). Project agents ignore it.
|
||||
*/
|
||||
clientContext?: () => Record<string, unknown> | undefined;
|
||||
}>(),
|
||||
{
|
||||
visible: true,
|
||||
|
|
@ -126,6 +131,7 @@ const props = withDefaults(
|
|||
centerEmptyState: false,
|
||||
budgetCards: false,
|
||||
increaseBudget: undefined,
|
||||
clientContext: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1064,7 +1070,10 @@ async function submitDraft(text: string, files: File[]): Promise<SubmitResult> {
|
|||
|
||||
const installedPreview = previewFirstMessage(text, files) ?? ownedHandoffPreview;
|
||||
let accepted = false;
|
||||
const sending = sendMessage(text, files.length > 0 ? files : undefined, (queueId) => {
|
||||
// Build the context after `beforeSend`, so it reflects the host state at send time.
|
||||
const clientContext = props.clientContext?.();
|
||||
const sentFiles = files.length > 0 ? files : undefined;
|
||||
const onAccepted = (queueId?: string) => {
|
||||
accepted = true;
|
||||
if (!isCurrentTarget()) return;
|
||||
if (installedPreview && firstMessagePreview.value === installedPreview) {
|
||||
|
|
@ -1083,7 +1092,8 @@ async function submitDraft(text: string, files: File[]): Promise<SubmitResult> {
|
|||
queueExpanded.value = false;
|
||||
consumeQueuedExternalMessage(text);
|
||||
trackSentToN8nChat(hadNoMessagesBeforeSend);
|
||||
});
|
||||
};
|
||||
const sending = sendMessage(text, sentFiles, onAccepted, clientContext);
|
||||
isPreparingToSend.value = false;
|
||||
const result = await sending;
|
||||
// A send the server never accepted (busy, failed, lost) won't bring the real
|
||||
|
|
|
|||
|
|
@ -1480,6 +1480,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
files?: File[],
|
||||
onAccepted?: (queueId?: string) => void,
|
||||
userMessage?: ChatMessage,
|
||||
clientContext?: Record<string, unknown>,
|
||||
) {
|
||||
const target = targetKey();
|
||||
const url = agentChatBaseUrl(
|
||||
|
|
@ -1496,6 +1497,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
body.messageId = userMessage?.id;
|
||||
if (newSession) body.newSession = true;
|
||||
}
|
||||
if (clientContext) body.clientContext = clientContext;
|
||||
if (files?.length) {
|
||||
body.attachments = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
|
|
@ -1663,10 +1665,15 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `clientContext` is per-message context that a system agent's provider reads.
|
||||
* The request body carries it only when it is set.
|
||||
*/
|
||||
async function sendMessage(
|
||||
text: string,
|
||||
files?: File[],
|
||||
onAccepted?: (queueId?: string) => void,
|
||||
clientContext?: Record<string, unknown>,
|
||||
): Promise<'sent' | 'busy'> {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && !files?.length) || isSubmitting.value || isLoadingHistory.value) return 'busy';
|
||||
|
|
@ -1702,6 +1709,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
release();
|
||||
},
|
||||
userMessage,
|
||||
clientContext,
|
||||
)
|
||||
.then(({ outcome }) => {
|
||||
release(outcome === 'busy' ? 'busy' : 'sent');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user