diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 92f0e0d58e2..62c61a87918 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts index 2d4be1d31c5..9ca9b9ec679 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-chat-message.dto.test.ts @@ -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({ diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 6ebbb80b170..4d8dffc35d4 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -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 diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index 59aeff27833..8a4487ac05d 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -366,6 +366,7 @@ describe('AgentChatPanel', () => { channel: 'chat' | 'n8n-chat'; centerEmptyState: boolean; newSession: boolean; + clientContext: () => Record | 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, 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(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts index 63b330847e4..7f7f8a04882 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts @@ -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; + }; + 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'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue index fb37b41a4f7..b4365cb13ea 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue @@ -111,6 +111,11 @@ const props = withDefaults( * increase action. Resolves true once the new cap is saved. */ increaseBudget?: (payload: { field: BudgetAmountField; amount: number }) => Promise; + /** + * Builds the client context that is sent with each new message (for + * example the user's time zone). Project agents ignore it. + */ + clientContext?: () => Record | 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 { 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 { 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 diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts index c21bfab119d..1b8fde07582 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts @@ -1480,6 +1480,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { files?: File[], onAccepted?: (queueId?: string) => void, userMessage?: ChatMessage, + clientContext?: Record, ) { 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, ): 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');