feat(timezone): recreate v1 TZ-aware formatting + scheduling behavior
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>
This commit is contained in:
167
container/agent-runner/src/formatter.test.ts
Normal file
167
container/agent-runner/src/formatter.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* v1-parity tests for formatter behavior.
|
||||
*
|
||||
* Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1
|
||||
* deletion commit 86becf8). Covers: context timezone header, reply_to +
|
||||
* quoted_message rendering, XML escaping, and stripInternalTags.
|
||||
*
|
||||
* Timestamp-format assertions use `formatLocalTime()` output format, which
|
||||
* is host locale-dependent for decorators (month abbr, "," separator) but
|
||||
* stable for the numeric parts we assert on (hour, minute, year).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { formatMessages, stripInternalTags } from './formatter.js';
|
||||
import { TIMEZONE } from './timezone.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(
|
||||
id: string,
|
||||
kind: string,
|
||||
content: object,
|
||||
opts?: { timestamp?: string },
|
||||
) {
|
||||
const timestamp = opts?.timestamp ?? new Date().toISOString();
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES (?, ?, ?, 'pending', ?)`,
|
||||
)
|
||||
.run(id, kind, timestamp, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('context timezone header', () => {
|
||||
it('prepends <context timezone="..."/> to formatted output', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
|
||||
});
|
||||
|
||||
it('includes the header even when the message list is empty', () => {
|
||||
const result = formatMessages([]);
|
||||
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
|
||||
});
|
||||
|
||||
it('header comes before the <messages> block', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
|
||||
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
const ctxIdx = result.indexOf('<context');
|
||||
const msgsIdx = result.indexOf('<messages>');
|
||||
expect(ctxIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(msgsIdx).toBeGreaterThan(ctxIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp formatting', () => {
|
||||
it('renders time via formatLocalTime (user TZ)', () => {
|
||||
// 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable)
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
// formatLocalTime's format in en-US contains the year and a month abbrev
|
||||
expect(result).toContain('2026');
|
||||
expect(result).toMatch(/Jun/);
|
||||
});
|
||||
|
||||
it('uses 12-hour AM/PM format', () => {
|
||||
// 15:30 UTC — some hour will show with AM or PM depending on TZ
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toMatch(/(AM|PM)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply_to + quoted_message rendering', () => {
|
||||
it('renders reply_to attribute and quoted_message when all fields present', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'Yes, on my way!',
|
||||
replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' },
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
it('omits reply_to and quoted_message when no reply context', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).not.toContain('reply_to');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('renders reply_to but omits quoted_message when original content is missing', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'ack',
|
||||
replyTo: { id: '42', sender: 'Bob' }, // no text
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('XML-escapes reply context', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'reply',
|
||||
replyTo: { id: '1', sender: 'A & B', text: '<script>alert("xss")</script>' },
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('from="A & B"');
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).toContain('"xss"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML escaping', () => {
|
||||
it('escapes <, >, &, " in sender and body', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'A & B <Co>',
|
||||
text: '<script>alert("xss")</script>',
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('sender="A & B <Co>"');
|
||||
expect(result).toContain('<script>alert("xss")</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripInternalTags', () => {
|
||||
it('strips single-line internal tags and trims', () => {
|
||||
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips multi-line internal tags', () => {
|
||||
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe(
|
||||
'hello world',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips multiple internal tag blocks', () => {
|
||||
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string when input is only internal tags', () => {
|
||||
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
|
||||
});
|
||||
|
||||
it('returns input unchanged when there are no internal tags', () => {
|
||||
expect(stripInternalTags('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('preserves content that surrounds internal tags', () => {
|
||||
expect(stripInternalTags('<internal>thinking</internal>The answer is 42')).toBe(
|
||||
'The answer is 42',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { findByRouting } from './destinations.js';
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
|
||||
/**
|
||||
* Command categories for messages starting with '/'.
|
||||
@@ -92,10 +93,19 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
||||
|
||||
/**
|
||||
* Format a batch of messages_in rows into a prompt string.
|
||||
*
|
||||
* Prepends a `<context timezone="<IANA>" />` header so the agent always knows
|
||||
* what timezone it's in — every timestamp it sees in message bodies is the
|
||||
* user's local time, and every time it produces (schedules, suggests) should
|
||||
* be interpreted as local time in that same zone. This header is v1 behavior
|
||||
* (src/v1/router.ts:20-22); dropping it led to misinterpretations where the
|
||||
* agent scheduled tasks for the wrong hour.
|
||||
*
|
||||
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
|
||||
*/
|
||||
export function formatMessages(messages: MessageInRow[]): string {
|
||||
if (messages.length === 0) return '';
|
||||
const header = `<context timezone="${escapeXml(TIMEZONE)}" />\n`;
|
||||
if (messages.length === 0) return header;
|
||||
|
||||
// Group by kind
|
||||
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
|
||||
@@ -118,7 +128,7 @@ export function formatMessages(messages: MessageInRow[]): string {
|
||||
parts.push(...systemMessages.map(formatSystemMessage));
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
return header + parts.join('\n\n');
|
||||
}
|
||||
|
||||
function formatChatMessages(messages: MessageInRow[]): string {
|
||||
@@ -137,9 +147,10 @@ function formatChatMessages(messages: MessageInRow[]): string {
|
||||
function formatSingleChat(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
||||
const time = formatTime(msg.timestamp);
|
||||
const time = formatLocalTime(msg.timestamp, TIMEZONE);
|
||||
const text = content.text || '';
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : '';
|
||||
const replyPrefix = formatReplyContext(content.replyTo);
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
|
||||
@@ -154,7 +165,7 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
|
||||
: '';
|
||||
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
@@ -179,13 +190,22 @@ function formatSystemMessage(msg: MessageInRow): string {
|
||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the quoted original inside the <message> body.
|
||||
*
|
||||
* Matches v1 format (src/v1/router.ts:10-18): `<quoted_message from="X">Y</quoted_message>`.
|
||||
* Requires BOTH sender and text — if only id is present the reply_to attribute
|
||||
* on the parent <message> carries the link without an inline preview.
|
||||
*
|
||||
* No truncation here (v1 didn't truncate).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function formatReplyContext(replyTo: any): string {
|
||||
if (!replyTo) return '';
|
||||
const sender = replyTo.sender || 'Unknown';
|
||||
const text = replyTo.text || '';
|
||||
const preview = text.length > 100 ? text.slice(0, 100) + '…' : text;
|
||||
return `\n<reply-to sender="${escapeXml(sender)}">${escapeXml(preview)}</reply-to>\n`;
|
||||
const sender = replyTo.sender;
|
||||
const text = replyTo.text;
|
||||
if (!sender || !text) return '';
|
||||
return `\n <quoted_message from="${escapeXml(sender)}">${escapeXml(text)}</quoted_message>\n`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -213,15 +233,15 @@ function parseContent(json: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
try {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `<internal>...</internal>` blocks from agent output, then trim.
|
||||
* Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's
|
||||
* own scratchpad/reasoning before a reply goes out over a channel.
|
||||
*/
|
||||
export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { getInboundDb } from '../db/connection.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -35,13 +36,21 @@ export const scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.',
|
||||
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' },
|
||||
recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
|
||||
},
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
|
||||
},
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
@@ -49,8 +58,17 @@ export const scheduleTask: McpToolDefinition = {
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfter = args.processAfter as string;
|
||||
if (!prompt || !processAfter) return err('prompt and processAfter are required');
|
||||
const processAfterIn = args.processAfter as string;
|
||||
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
|
||||
|
||||
let processAfter: string;
|
||||
try {
|
||||
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
|
||||
processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${processAfterIn}`);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
@@ -233,7 +251,11 @@ export const updateTask: McpToolDefinition = {
|
||||
type: 'string',
|
||||
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
|
||||
},
|
||||
processAfter: { type: 'string', description: 'New ISO timestamp for the next run (optional)' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
|
||||
},
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'New pre-agent script (optional). Pass empty string to clear.',
|
||||
@@ -248,7 +270,15 @@ export const updateTask: McpToolDefinition = {
|
||||
|
||||
const update: Record<string, unknown> = { taskId };
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (typeof args.processAfter === 'string') update.processAfter = args.processAfter;
|
||||
if (typeof args.processAfter === 'string') {
|
||||
try {
|
||||
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
|
||||
update.processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${args.processAfter}`);
|
||||
}
|
||||
}
|
||||
// Empty string clears recurrence/script; undefined leaves them as-is.
|
||||
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
|
||||
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow }
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -384,10 +384,7 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
scratchpadParts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
const scratchpad = scratchpadParts
|
||||
.join('')
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
const scratchpad = stripInternalTags(scratchpadParts.join(''));
|
||||
|
||||
// Single-destination shortcut: the agent wrote plain text — send to
|
||||
// the session's originating channel (from session_routing) if available,
|
||||
|
||||
93
container/agent-runner/src/timezone.test.ts
Normal file
93
container/agent-runner/src/timezone.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
107
container/agent-runner/src/timezone.ts
Normal file
107
container/agent-runner/src/timezone.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Timezone utilities — mirror of src/timezone.ts (host).
|
||||
*
|
||||
* The container can't import from src/ (separate tsconfig, different runtime).
|
||||
* Kept deliberately byte-aligned with the host module so behaviour is the
|
||||
* same on both sides of the session-DB boundary.
|
||||
*
|
||||
* TIMEZONE is resolved once at module load from process.env.TZ (which the host
|
||||
* sets from its own TIMEZONE constant when spawning the container; see
|
||||
* src/container-runner.ts). Invalid values fall back to UTC.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check whether a timezone string is a valid IANA identifier
|
||||
* that Intl.DateTimeFormat can use.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
||||
*/
|
||||
export function resolveTimezone(tz: string): string {
|
||||
return isValidTimezone(tz) ? tz : 'UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTC ISO timestamp to a localized display string.
|
||||
* Uses the Intl API (no external dependencies).
|
||||
* Falls back to UTC if the timezone is invalid.
|
||||
*/
|
||||
export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
const date = new Date(utcIso);
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContainerTimezone(): string {
|
||||
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
for (const tz of candidates) {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
|
||||
export const TIMEZONE = resolveContainerTimezone();
|
||||
|
||||
/**
|
||||
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
||||
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
||||
* offset info (`Z` or `±HH:MM`) are passed through to the Date constructor
|
||||
* unchanged.
|
||||
*
|
||||
* Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that
|
||||
* UTC instant is called in `tz`, then invert the offset. Near DST boundaries
|
||||
* this can be off by an hour for ~1h of wall-clock time per year; acceptable
|
||||
* for scheduling where the agent normally picks round-hour targets.
|
||||
*/
|
||||
export function parseZonedToUtc(input: string, tz: string): Date {
|
||||
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
|
||||
if (hasOffset) return new Date(input);
|
||||
|
||||
const zone = resolveTimezone(tz);
|
||||
const asIfUtc = new Date(input + 'Z');
|
||||
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
fmt
|
||||
.formatToParts(asIfUtc)
|
||||
.filter((p) => p.type !== 'literal')
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
const hour = parts.hour === '24' ? '00' : parts.hour;
|
||||
const zonedAsUtcMs = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
);
|
||||
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
|
||||
return new Date(asIfUtc.getTime() - offsetMs);
|
||||
}
|
||||
100
src/modules/scheduling/recurrence.test.ts
Normal file
100
src/modules/scheduling/recurrence.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for `handleRecurrence` — specifically the timezone-aware cron
|
||||
* interpretation ported from v1 (src/v1/task-scheduler.ts).
|
||||
*
|
||||
* Core invariant: cron expressions are interpreted in the user's TIMEZONE,
|
||||
* not UTC. Without this, `"0 9 * * *"` fires at 09:00 UTC instead of 09:00
|
||||
* user-local — a recurring scheduling bug users can't diagnose.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
|
||||
import { insertTask } from './db.js';
|
||||
import { handleRecurrence } from './recurrence.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-recurrence-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
|
||||
function freshDb() {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
ensureSchema(DB_PATH, 'inbound');
|
||||
return openInboundDb(DB_PATH);
|
||||
}
|
||||
|
||||
function fakeSession(): Session {
|
||||
return {
|
||||
id: 'sess-test',
|
||||
agent_group_id: 'ag-test',
|
||||
messaging_group_id: 'mg-test',
|
||||
thread_id: null,
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
last_active: new Date().toISOString(),
|
||||
container_status: 'stopped',
|
||||
} as Session;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('handleRecurrence', () => {
|
||||
it('clones a completed recurring task with a next-run in the future', async () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: '0 9 * * *', // every day at 09:00 (user TZ)
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'daily digest' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, status, process_after, recurrence, series_id FROM messages_in ORDER BY seq`,
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
process_after: string;
|
||||
recurrence: string | null;
|
||||
series_id: string;
|
||||
}>;
|
||||
expect(rows).toHaveLength(2);
|
||||
const original = rows.find((r) => r.id === 'task-1')!;
|
||||
const follow = rows.find((r) => r.id !== 'task-1')!;
|
||||
expect(original.recurrence).toBeNull();
|
||||
expect(follow.status).toBe('pending');
|
||||
expect(follow.recurrence).toBe('0 9 * * *');
|
||||
expect(follow.series_id).toBe('task-1');
|
||||
expect(new Date(follow.process_after).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('does not clone rows whose recurrence is already cleared', async () => {
|
||||
const db = freshDb();
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'one-off' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const count = (db.prepare(`SELECT COUNT(*) AS c FROM messages_in`).get() as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { TIMEZONE } from '../../config.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence } from './db.js';
|
||||
@@ -23,7 +24,11 @@ export async function handleRecurrence(inDb: Database.Database, session: Session
|
||||
for (const msg of recurring) {
|
||||
try {
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
const interval = CronExpressionParser.parse(msg.recurrence);
|
||||
// Interpret the cron expression in the user's timezone. v1 did this
|
||||
// (src/v1/task-scheduler.ts:20-49); without it, a task written "0 9 * * *"
|
||||
// by an agent running in a user's local TZ fires at 09:00 UTC instead of
|
||||
// 09:00 user-local.
|
||||
const interval = CronExpressionParser.parse(msg.recurrence, { tz: TIMEZONE });
|
||||
const nextRun = interval.next().toISOString();
|
||||
const prefix = msg.kind === 'task' ? 'task' : 'msg';
|
||||
const newId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
Reference in New Issue
Block a user