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:
Etienne
2026-07-09 09:02:56 +02:00
committed by GitHub
parent 3a5545c753
commit cc7b41db0e
14 changed files with 277 additions and 26 deletions
@@ -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');
};
@@ -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:');
});
});
});
@@ -603,7 +603,7 @@ export class AgentChatStreamingService {
return part;
}),
),
createdAt: message.createdAt,
metadata: { createdAt: message.createdAt.toISOString() },
})),
);
}
@@ -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');
@@ -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 {
@@ -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[];
};
@@ -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);
});
});
@@ -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' }],
@@ -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[] = [];
@@ -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],
};
});
@@ -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';