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>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
/**
|
|
* 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',
|
|
);
|
|
});
|
|
});
|