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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user