Files
nanoclaw/container/agent-runner/src/formatter.test.ts
gavrielc dcfa12ea06 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>
2026-04-20 01:09:14 +03:00

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 &amp; B"');
expect(result).toContain('&lt;script&gt;');
expect(result).toContain('&quot;xss&quot;');
});
});
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 &amp; B &lt;Co&gt;"');
expect(result).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
});
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',
);
});
});