feat(ai-chat): inject current date & per-message timestamps into agent context (#22632)
## Summary
Gives the AI chat agent temporal awareness by injecting the current date
into
the system prompt and a per-message "sent at" timestamp into each user
message,
formatted in the member's timezone. Also hardens all timezone formatting
against
the `"system"` sentinel value, which was crashing the stream job.
## What changed
**Message timestamps (new)**
- Added `injectMessageTimestamps` util: prepends a
`<message_timestamp>Sent: …</message_timestamp>`
text part to each user message before it's sent to the model, so the
agent can
reason about "yesterday", "last week", etc.
- `loadMessagesFromDB` now stores the message time in the canonical
`metadata.createdAt` slot (ISO string, JSON-serializable for the BullMQ
job
payload) instead of a non-typed top-level `createdAt` field that nothing
read.
- Migrated the AI chat message pipeline from the generic `UIMessage` to
the
typed `ExtendedUIMessage` (`chat-execution.service`,
`extract-code-interpreter-files`,
`replace-unsupported-file-parts`, and related types), since
`metadata.createdAt`
is declared on `ExtendedUIMessage`.
**Current date in context**
- System prompt now includes `Current date: …` formatted in the member's
timezone
(`system-prompt-builder.service`).
- Settings › AI prompt preview mirrors the same `Current date` line.
**Timezone safety (bug fix)**
- Workspace members default `timeZone` to the `"system"` sentinel, which
is only
resolvable client-side. Passing it (or any invalid IANA zone) to
`Intl.DateTimeFormat` throws `RangeError: Invalid time zone specified:
system`,
which was failing the stream job.
- Added `getValidTimeZoneOrUndefined`, which returns a valid IANA zone
or
`undefined` (letting the runtime fall back to its default). Used in both
`injectMessageTimestamps` and `formatCurrentDate`. This mirrors the
existing
`isValidTimeZone` convention in the calendar module.
## Notes / follow-ups
- For members who never changed `timeZone` from `"system"`, timestamps
fall back
to the server's default zone (UTC). To honor their real local time, the
frontend would need to send the browser-detected zone with the chat
request
(the same way calendar/charts already pass a resolved zone). Not
included here.
## Test plan
- [x] `inject-message-timestamps.util.spec.ts` — covers timestamp
injection,
assistant messages untouched, invalid `createdAt`, and the `"system"`
timezone no longer throwing.
- [ ] Send a chat message and confirm the agent sees the correct
date/time.
- [ ] Verify a member with `timeZone = "system"` no longer crashes the
stream job.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22632?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -8,7 +8,11 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
getSettingsPath,
|
||||
getValidTimeZoneOrUndefined,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { H2Title, H3Title } from 'twenty-ui/typography';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
@@ -48,10 +52,24 @@ export const SettingsAiPrompts = () => {
|
||||
`**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`,
|
||||
];
|
||||
|
||||
if (isDefined(currentWorkspaceMember.timeZone)) {
|
||||
parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`);
|
||||
const validTimeZone = getValidTimeZoneOrUndefined(
|
||||
currentWorkspaceMember.timeZone,
|
||||
);
|
||||
|
||||
if (isDefined(validTimeZone)) {
|
||||
parts.push(`**${t`Timezone`}:** ${validTimeZone}`);
|
||||
}
|
||||
|
||||
const currentDate = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: validTimeZone,
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date());
|
||||
|
||||
parts.push(`**${t`Current date`}:** ${currentDate}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
|
||||
|
||||
describe('SystemPromptBuilderService', () => {
|
||||
const buildService = () =>
|
||||
new SystemPromptBuilderService({} as never, {} as never, {} as never);
|
||||
|
||||
describe('buildUserContextSection', () => {
|
||||
it('omits the timezone line when timezone is the "system" sentinel', () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = service.buildUserContextSection({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
locale: 'en',
|
||||
timezone: 'system',
|
||||
});
|
||||
|
||||
expect(result).not.toContain('Timezone:');
|
||||
expect(result).toContain('Current date:');
|
||||
});
|
||||
|
||||
it('includes the timezone line for a valid IANA timezone', () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = service.buildUserContextSection({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
locale: 'en',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(result).toContain('Timezone: America/New_York');
|
||||
expect(result).toContain('Current date:');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -603,7 +603,7 @@ export class AgentChatStreamingService {
|
||||
return part;
|
||||
}),
|
||||
),
|
||||
createdAt: message.createdAt,
|
||||
metadata: { createdAt: message.createdAt.toISOString() },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
+11
-7
@@ -10,10 +10,8 @@ import {
|
||||
streamText,
|
||||
type SystemModelMessage,
|
||||
type ToolSet,
|
||||
type UIDataTypes,
|
||||
type UIMessage,
|
||||
type UITools,
|
||||
} from 'ai';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath, isDefined } from 'twenty-shared/utils';
|
||||
@@ -66,6 +64,7 @@ import { MessagePruningService } from 'src/engine/metadata-modules/ai/ai-chat/se
|
||||
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
|
||||
import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type';
|
||||
import { extractCodeInterpreterFiles } from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util';
|
||||
import { injectMessageTimestamps } from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util';
|
||||
import {
|
||||
getCacheProviderOptions,
|
||||
getCallLevelProviderOptions,
|
||||
@@ -83,7 +82,7 @@ export type ChatExecutionOptions = {
|
||||
workspace: WorkspaceEntity;
|
||||
userWorkspaceId: string;
|
||||
threadId?: string;
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
messages: ExtendedUIMessage[];
|
||||
browsingContext: BrowsingContextType | null;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
onCompaction?: () => void;
|
||||
@@ -236,7 +235,7 @@ export class ChatExecutionService {
|
||||
|
||||
const isCodeInterpreterEnabled = this.codeInterpreterService.isEnabled();
|
||||
|
||||
let processedMessages: UIMessage[] = replaceUnsupportedFileParts(
|
||||
let processedMessages: ExtendedUIMessage[] = replaceUnsupportedFileParts(
|
||||
messages,
|
||||
modelConfig.modalities,
|
||||
isCodeInterpreterEnabled,
|
||||
@@ -272,6 +271,11 @@ export class ChatExecutionService {
|
||||
);
|
||||
}
|
||||
|
||||
processedMessages = injectMessageTimestamps(
|
||||
processedMessages,
|
||||
userContext.timezone,
|
||||
);
|
||||
|
||||
const systemPrompt = this.systemPromptBuilder.buildFullPrompt(
|
||||
toolCatalog,
|
||||
skillCatalog,
|
||||
@@ -622,9 +626,9 @@ export class ChatExecutionService {
|
||||
}
|
||||
|
||||
private injectBrowsingContextIntoLastUserMessage(
|
||||
messages: UIMessage[],
|
||||
messages: ExtendedUIMessage[],
|
||||
contextString: string,
|
||||
): UIMessage[] {
|
||||
): ExtendedUIMessage[] {
|
||||
const lastUserIndex = messages
|
||||
.map((message) => message.role)
|
||||
.lastIndexOf('user');
|
||||
|
||||
+20
-3
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import {
|
||||
assertUnreachable,
|
||||
getValidTimeZoneOrUndefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
@@ -182,16 +185,30 @@ ${instructions}`;
|
||||
`Locale: ${userContext.locale}`,
|
||||
];
|
||||
|
||||
if (userContext.timezone) {
|
||||
parts.push(`Timezone: ${userContext.timezone}`);
|
||||
const resolvedTimeZone = getValidTimeZoneOrUndefined(userContext.timezone);
|
||||
|
||||
if (resolvedTimeZone) {
|
||||
parts.push(`Timezone: ${resolvedTimeZone}`);
|
||||
}
|
||||
|
||||
parts.push(`Current date: ${this.formatCurrentDate(userContext.timezone)}`);
|
||||
|
||||
return `
|
||||
## User Context
|
||||
|
||||
${parts.join('\n')}`;
|
||||
}
|
||||
|
||||
private formatCurrentDate(timezone: string | null): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: getValidTimeZoneOrUndefined(timezone),
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
buildUploadedFilesSection(
|
||||
storedFiles: Array<{ filename: string; fileId: string }>,
|
||||
): string {
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { type UIMessage } from 'ai';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type';
|
||||
|
||||
export type ExtractCodeInterpreterFilesResult = {
|
||||
processedMessages: UIMessage[];
|
||||
processedMessages: ExtendedUIMessage[];
|
||||
extractedFiles: ExtractedFile[];
|
||||
};
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
import { injectMessageTimestamps } from 'src/engine/metadata-modules/ai/ai-chat/utils/inject-message-timestamps.util';
|
||||
|
||||
describe('injectMessageTimestamps', () => {
|
||||
it('prefixes a user message with a timestamp part built from metadata.createdAt', () => {
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'What happened yesterday?' }],
|
||||
metadata: { createdAt: '2026-07-07T09:57:00.000Z' },
|
||||
},
|
||||
] as unknown as ExtendedUIMessage[];
|
||||
|
||||
const [message] = injectMessageTimestamps(messages, 'UTC');
|
||||
|
||||
expect(message.parts).toHaveLength(2);
|
||||
expect(message.parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('<message_timestamp>Sent: '),
|
||||
});
|
||||
expect((message.parts[0] as { text: string }).text).toContain('UTC');
|
||||
expect(message.parts[1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'What happened yesterday?',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves assistant messages untouched', () => {
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'response' }],
|
||||
metadata: { createdAt: '2026-07-07T09:57:00.000Z' },
|
||||
},
|
||||
] as unknown as ExtendedUIMessage[];
|
||||
|
||||
const [message] = injectMessageTimestamps(messages, 'UTC');
|
||||
|
||||
expect(message.parts).toHaveLength(1);
|
||||
expect(message.parts[0]).toEqual({ type: 'text', text: 'response' });
|
||||
});
|
||||
|
||||
it('does not throw and still injects a timestamp when the timezone is the "system" sentinel', () => {
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'What happened yesterday?' }],
|
||||
metadata: { createdAt: '2026-07-07T09:57:00.000Z' },
|
||||
},
|
||||
] as unknown as ExtendedUIMessage[];
|
||||
|
||||
const [message] = injectMessageTimestamps(messages, 'system');
|
||||
|
||||
expect(message.parts).toHaveLength(2);
|
||||
expect(message.parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('<message_timestamp>Sent: '),
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves user messages without a valid createdAt untouched', () => {
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'world' }],
|
||||
metadata: { createdAt: 'not-a-date' },
|
||||
},
|
||||
] as unknown as ExtendedUIMessage[];
|
||||
|
||||
const result = injectMessageTimestamps(messages, 'UTC');
|
||||
|
||||
expect(result[0].parts).toHaveLength(1);
|
||||
expect(result[1].parts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+5
-3
@@ -1,4 +1,4 @@
|
||||
import { type UIMessage } from 'ai';
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
|
||||
import { replaceUnsupportedFileParts } from 'src/engine/metadata-modules/ai/ai-chat/utils/replace-unsupported-file-parts.util';
|
||||
|
||||
@@ -10,7 +10,9 @@ const buildFilePart = (mediaType: string, filename = 'file.bin') => ({
|
||||
fileId: 'file-id',
|
||||
});
|
||||
|
||||
const buildUserMessage = (parts: UIMessage['parts']): UIMessage => ({
|
||||
const buildUserMessage = (
|
||||
parts: ExtendedUIMessage['parts'],
|
||||
): ExtendedUIMessage => ({
|
||||
id: 'message-id',
|
||||
role: 'user',
|
||||
parts,
|
||||
@@ -65,7 +67,7 @@ describe('replaceUnsupportedFileParts', () => {
|
||||
});
|
||||
|
||||
it('does not touch non-user messages', () => {
|
||||
const assistantMessage: UIMessage = {
|
||||
const assistantMessage: ExtendedUIMessage = {
|
||||
id: 'assistant-id',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
|
||||
+2
-3
@@ -1,12 +1,11 @@
|
||||
import { type UIMessage } from 'ai';
|
||||
import { isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||
import { type ExtendedUIMessage, isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||
|
||||
import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant';
|
||||
import { type ExtractCodeInterpreterFilesResult } from 'src/engine/metadata-modules/ai/ai-chat/types/extract-code-interpreter-files-result.type';
|
||||
import { type ExtractedFile } from 'src/engine/metadata-modules/ai/ai-chat/types/extracted-file.type';
|
||||
|
||||
export const extractCodeInterpreterFiles = (
|
||||
messages: UIMessage[],
|
||||
messages: ExtendedUIMessage[],
|
||||
): ExtractCodeInterpreterFilesResult => {
|
||||
const extractedFiles: ExtractedFile[] = [];
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { type ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { getValidTimeZoneOrUndefined, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const formatMessageTimestamp = (date: Date, timezone: string | null): string =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: getValidTimeZoneOrUndefined(timezone),
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
}).format(date);
|
||||
|
||||
const extractCreatedAt = (message: ExtendedUIMessage): Date | undefined => {
|
||||
const rawCreatedAt = message.metadata?.createdAt;
|
||||
|
||||
if (!isDefined(rawCreatedAt)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedCreatedAt = new Date(rawCreatedAt);
|
||||
|
||||
return isNaN(parsedCreatedAt.getTime()) ? undefined : parsedCreatedAt;
|
||||
};
|
||||
|
||||
export const injectMessageTimestamps = (
|
||||
messages: ExtendedUIMessage[],
|
||||
timezone: string | null,
|
||||
): ExtendedUIMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role !== 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const createdAt = extractCreatedAt(message);
|
||||
|
||||
if (!isDefined(createdAt)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const timestampPart = {
|
||||
type: 'text' as const,
|
||||
text: `<message_timestamp>Sent: ${formatMessageTimestamp(createdAt, timezone)}</message_timestamp>`,
|
||||
};
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: [timestampPart, ...message.parts],
|
||||
};
|
||||
});
|
||||
+3
-4
@@ -1,14 +1,13 @@
|
||||
import { type UIMessage } from 'ai';
|
||||
import { isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||
import { type ExtendedUIMessage, isExtendedFileUIPart } from 'twenty-shared/ai';
|
||||
|
||||
import { CODE_INTERPRETER_MIME_TYPES } from 'src/engine/metadata-modules/ai/ai-chat/constants/code-interpreter-mime-types.constant';
|
||||
import { getNativeMimeTypesForModalities } from 'src/engine/metadata-modules/ai/ai-chat/utils/get-native-mime-types-for-modalities.util';
|
||||
|
||||
export const replaceUnsupportedFileParts = (
|
||||
messages: UIMessage[],
|
||||
messages: ExtendedUIMessage[],
|
||||
modalities: string[] = [],
|
||||
isCodeInterpreterEnabled: boolean,
|
||||
): UIMessage[] => {
|
||||
): ExtendedUIMessage[] => {
|
||||
const nativeMimeTypes = getNativeMimeTypesForModalities(modalities);
|
||||
|
||||
return messages.map((message) => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getValidTimeZoneOrUndefined } from '@/utils/date/getValidTimeZoneOrUndefined';
|
||||
|
||||
describe('getValidTimeZoneOrUndefined', () => {
|
||||
it('should return undefined for null, undefined, empty string, and system', () => {
|
||||
expect(getValidTimeZoneOrUndefined(null)).toBeUndefined();
|
||||
expect(getValidTimeZoneOrUndefined(undefined)).toBeUndefined();
|
||||
expect(getValidTimeZoneOrUndefined('')).toBeUndefined();
|
||||
expect(getValidTimeZoneOrUndefined('system')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid IANA time zones', () => {
|
||||
expect(getValidTimeZoneOrUndefined('Not/A_Timezone')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the time zone for valid IANA time zones', () => {
|
||||
expect(getValidTimeZoneOrUndefined('America/New_York')).toBe(
|
||||
'America/New_York',
|
||||
);
|
||||
expect(getValidTimeZoneOrUndefined('UTC')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getValidTimeZoneOrUndefined = (
|
||||
timeZone: string | null | undefined,
|
||||
): string | undefined => {
|
||||
if (!isNonEmptyString(timeZone) || timeZone === 'system') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone });
|
||||
|
||||
return timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
ACCEPTED_DATE_FORMATS,
|
||||
ACCEPTED_DATE_TIME_FORMATS,
|
||||
} from './date/dateInputFormats';
|
||||
export { getValidTimeZoneOrUndefined } from './date/getValidTimeZoneOrUndefined';
|
||||
export { isDateWithoutTime } from './date/isDateWithoutTime';
|
||||
export { isPlainDateAfter } from './date/isPlainDateAfter';
|
||||
export { isPlainDateBefore } from './date/isPlainDateBefore';
|
||||
|
||||
Reference in New Issue
Block a user