The agent needs to perceive times in the user's timezone, not UTC.
Dropping this in the v1→v2 port produced a class of bugs where the agent
would schedule tasks for the wrong hour, suggest dinner at midnight, etc.
This restores v1 parity.
Container side:
- New container/agent-runner/src/timezone.ts mirrors src/timezone.ts with
isValidTimezone / resolveTimezone / formatLocalTime, plus:
* TIMEZONE constant resolved at load from process.env.TZ (host sets this
from src/container-runner.ts:254)
* parseZonedToUtc(input, tz) — treats a naive ISO as wall-clock time in
`tz`, returns the corresponding UTC Date. Strings with Z or offset
are passed through.
- formatter.ts:
* formatMessages() now prepends <context timezone="IANA"/>\n — matches
v1 src/v1/router.ts:20-22
* formatSingleChat uses formatLocalTime(ts, TIMEZONE) instead of a
home-rolled HH:MM 24h formatter → outputs like "Jun 15, 2026, 8:00 AM"
* reply_to="<id>" attribute + <quoted_message from="X">Y</quoted_message>
element — matches v1 format exactly; old <reply-to/> shape is gone
* stripInternalTags() exported for the dispatch path to reuse
- poll-loop.ts uses the exported stripInternalTags() instead of inline regex.
- mcp-tools/scheduling.ts:
* schedule_task/update_task descriptions now explicitly document that
processAfter accepts either UTC or naive local time (interpreted in
the user's TZ from the context header)
* handlers normalize through parseZonedToUtc() and store a UTC ISO
Host side:
- src/modules/scheduling/recurrence.ts passes { tz: TIMEZONE } to
CronExpressionParser.parse. Without this, "0 9 * * *" fires at 09:00
UTC instead of 09:00 user-local — this was the v1 behavior
(src/v1/task-scheduler.ts:20-49).
Tests:
- container/agent-runner/src/timezone.test.ts — mirror of src/timezone.test.ts
+ new parseZonedToUtc cases
- container/agent-runner/src/formatter.test.ts — context header, reply_to,
quoted_message, XML escaping, stripInternalTags (ported from v1
formatting.test.ts)
- src/modules/scheduling/recurrence.test.ts — cron TZ respected, completed
rows only cloned when recurrence is set
Ref: docs/v1-vs-v2/ACTION-ITEMS.md item 18 + timezone-formatting-v1-recreation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
import { describe, it, expect } from 'bun:test';
|
|
|
|
import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js';
|
|
|
|
// --- formatLocalTime ---
|
|
|
|
describe('formatLocalTime', () => {
|
|
it('converts UTC to local time display', () => {
|
|
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
|
|
const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York');
|
|
expect(result).toContain('1:30');
|
|
expect(result).toContain('PM');
|
|
expect(result).toContain('Feb');
|
|
expect(result).toContain('2026');
|
|
});
|
|
|
|
it('handles different timezones', () => {
|
|
// Same UTC time should produce different local times
|
|
const utc = '2026-06-15T12:00:00.000Z';
|
|
const ny = formatLocalTime(utc, 'America/New_York');
|
|
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
|
|
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
|
|
expect(ny).toContain('8:00');
|
|
expect(tokyo).toContain('9:00');
|
|
});
|
|
|
|
it('does not throw on invalid timezone, falls back to UTC', () => {
|
|
expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow();
|
|
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
|
|
// Should format as UTC (noon UTC = 12:00 PM)
|
|
expect(result).toContain('12:00');
|
|
expect(result).toContain('PM');
|
|
});
|
|
});
|
|
|
|
describe('isValidTimezone', () => {
|
|
it('accepts valid IANA identifiers', () => {
|
|
expect(isValidTimezone('America/New_York')).toBe(true);
|
|
expect(isValidTimezone('UTC')).toBe(true);
|
|
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
|
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid timezone strings', () => {
|
|
expect(isValidTimezone('IST-2')).toBe(false);
|
|
expect(isValidTimezone('XYZ+3')).toBe(false);
|
|
});
|
|
|
|
it('rejects empty and garbage strings', () => {
|
|
expect(isValidTimezone('')).toBe(false);
|
|
expect(isValidTimezone('NotATimezone')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveTimezone', () => {
|
|
it('returns the timezone if valid', () => {
|
|
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
|
|
});
|
|
|
|
it('falls back to UTC for invalid timezone', () => {
|
|
expect(resolveTimezone('IST-2')).toBe('UTC');
|
|
expect(resolveTimezone('')).toBe('UTC');
|
|
});
|
|
});
|
|
|
|
describe('parseZonedToUtc', () => {
|
|
it('passes strings with Z suffix through unchanged', () => {
|
|
const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York');
|
|
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
|
|
});
|
|
|
|
it('passes strings with numeric offset through unchanged', () => {
|
|
const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York');
|
|
expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z');
|
|
});
|
|
|
|
it('interprets naive ISO as wall-clock in the given timezone', () => {
|
|
// 09:00 naive in NY in January = 09:00 EST = 14:00 UTC
|
|
const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York');
|
|
expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z');
|
|
});
|
|
|
|
it('handles a different positive-offset zone', () => {
|
|
// 09:00 naive in Tokyo (UTC+9) = 00:00 UTC
|
|
const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo');
|
|
expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z');
|
|
});
|
|
|
|
it('treats invalid timezone as UTC', () => {
|
|
const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone');
|
|
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
|
|
});
|
|
});
|