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:
Jaakko Husso 2026-10-09 02:15:24 +03:00
parent 0a82909e13
commit cfb7b8f287
No known key found for this signature in database
7 changed files with 200 additions and 10 deletions

View File

@ -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,

View File

@ -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({

View File

@ -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

View File

@ -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();

View File

@ -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');
});
});

View File

@ -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

View File

@ -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');