🕐 fix: Use client timezone for special variable date formatting

`replaceSpecialVars` uses `dayjs()` which defaults to the server's
timezone (UTC in Docker), causing `{{current_datetime}}` and
`{{current_date}}` to always show +00:00 regardless of the user's
location. This is inconsistent with `append_current_datetime` for
assistants which already handles client timezone via `clientTimestamp`.

Add dayjs timezone plugin support and a `timezone` parameter to
`replaceSpecialVars`. The client sends its IANA timezone string
(`Intl.DateTimeFormat().resolvedOptions().timeZone`) alongside the
existing `clientTimestamp`, and the agent initialization flow passes
it through to produce timezone-aware date formatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lionel Ringenbach 2026-03-27 00:02:26 -07:00
parent f277b32030
commit 736960e0a4
6 changed files with 86 additions and 4 deletions

View file

@ -208,6 +208,7 @@ export default function useChatFunctions({
text,
sender: 'User',
clientTimestamp: new Date().toLocaleString('sv').replace(' ', 'T'),
clientTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
isCreatedByUser: true,
parentMessageId,
conversationId,

View file

@ -403,6 +403,7 @@ export async function initializeAgent(
agent.instructions = replaceSpecialVars({
text: agent.instructions,
user: req.user ? (req.user as unknown as TUser) : null,
timezone: req.body?.clientTimezone,
});
}

View file

@ -16,6 +16,7 @@ export type RequestBody = {
model?: string;
key?: string;
endpointOption?: Partial<TEndpointOption>;
clientTimezone?: string;
};
export type ServerRequest = Request<unknown, unknown, RequestBody> & {

View file

@ -7,13 +7,13 @@ import type { TUser, TConversation } from '../src/types';
// Mock dayjs module with consistent date/time values regardless of environment
jest.mock('dayjs', () => {
const mockDayjs = () => ({
const makeMock = (offset: string) => ({
format: (format: string) => {
if (format === 'YYYY-MM-DD') {
return '2024-04-29';
}
if (format === 'YYYY-MM-DD HH:mm:ss Z') {
return '2024-04-29 12:34:56 -04:00';
return `2024-04-29 12:34:56 ${offset}`;
}
if (format === 'dddd') {
return 'Monday';
@ -23,8 +23,19 @@ jest.mock('dayjs', () => {
);
},
toISOString: () => '2024-04-29T16:34:56.000Z',
isValid: () => true,
tz: (timezone: string) => {
if (timezone === 'America/New_York') {
return makeMock('-04:00');
}
if (timezone === 'Asia/Tokyo') {
return makeMock('+09:00');
}
return { isValid: () => false };
},
});
const mockDayjs = () => makeMock('-04:00');
mockDayjs.extend = jest.fn();
return mockDayjs;
@ -126,6 +137,56 @@ describe('replaceSpecialVars', () => {
expect(result).toContain('2024-04-29T16:34:56.000Z'); // iso_datetime
expect(result).toContain('Test User'); // current_user
});
test('should use provided timezone for date formatting', () => {
const result = replaceSpecialVars({
text: 'Now is {{current_datetime}}',
timezone: 'Asia/Tokyo',
});
expect(result).toBe('Now is 2024-04-29 12:34:56 +09:00 (Monday)');
});
test('should fall back to default when no timezone is provided', () => {
const result = replaceSpecialVars({
text: 'Now is {{current_datetime}}',
});
expect(result).toBe('Now is 2024-04-29 12:34:56 -04:00 (Monday)');
});
test('should fall back to default for invalid timezone', () => {
const result = replaceSpecialVars({
text: 'Now is {{current_datetime}}',
timezone: 'Invalid/Timezone',
});
expect(result).toBe('Now is 2024-04-29 12:34:56 -04:00 (Monday)');
});
test('should apply timezone to {{current_date}} formatting', () => {
const result = replaceSpecialVars({
text: 'Today is {{current_date}}',
timezone: 'Asia/Tokyo',
});
expect(result).toBe('Today is 2024-04-29 (Monday)');
});
test('should not affect {{iso_datetime}} regardless of timezone', () => {
const result = replaceSpecialVars({
text: 'ISO: {{iso_datetime}}',
timezone: 'Asia/Tokyo',
});
expect(result).toBe('ISO: 2024-04-29T16:34:56.000Z');
});
test('should handle all variables with timezone and user combined', () => {
const result = replaceSpecialVars({
text: '{{current_user}} - {{current_date}} - {{current_datetime}} - {{iso_datetime}}',
user: mockUser,
timezone: 'Asia/Tokyo',
});
expect(result).toBe(
'Test User - 2024-04-29 (Monday) - 2024-04-29 12:34:56 +09:00 (Monday) - 2024-04-29T16:34:56.000Z',
);
});
});
describe('parseCompactConvo', () => {

View file

@ -1,5 +1,10 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import tz from 'dayjs/plugin/timezone';
import type { ZodIssue } from 'zod';
dayjs.extend(utc);
dayjs.extend(tz);
import type * as a from './types/assistants';
import type * as s from './schemas';
import type * as t from './types';
@ -402,13 +407,24 @@ export function findLastSeparatorIndex(text: string, separators = SEPARATORS): n
return lastIndex;
}
export function replaceSpecialVars({ text, user }: { text: string; user?: t.TUser | null }) {
export function replaceSpecialVars({
text,
user,
timezone,
}: {
text: string;
user?: t.TUser | null;
timezone?: string;
}) {
let result = text;
if (!result) {
return result;
}
const now = dayjs();
let now = timezone ? dayjs().tz(timezone) : dayjs();
if (!now.isValid()) {
now = dayjs();
}
const weekdayName = now.format('dddd');
const currentDate = now.format('YYYY-MM-DD');

View file

@ -611,6 +611,7 @@ export const tMessageSchema = z.object({
isCreatedByUser: z.boolean(),
error: z.boolean().optional(),
clientTimestamp: z.string().optional(),
clientTimezone: z.string().optional(),
createdAt: z
.string()
.optional()
@ -686,6 +687,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
siblingIndex?: number;
attachments?: TAttachment[];
clientTimestamp?: string;
clientTimezone?: string;
feedback?: TFeedback;
};