diff --git a/container/agent-runner/src/formatter.test.ts b/container/agent-runner/src/formatter.test.ts new file mode 100644 index 0000000..e34156c --- /dev/null +++ b/container/agent-runner/src/formatter.test.ts @@ -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 to formatted output', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' }); + const result = formatMessages(getPendingMessages()); + expect(result).toContain(` { + const result = formatMessages([]); + expect(result).toContain(` block', () => { + insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); + insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); + const result = formatMessages(getPendingMessages()); + const ctxIdx = result.indexOf(''); + 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('Are you coming tonight?'); + expect(result).toContain('Yes, on my way!'); + }); + + 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: '' }, + }); + 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 ', + text: '', + }); + 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 secret world')).toBe('hello world'); + }); + + it('strips multi-line internal tags', () => { + expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe( + 'hello world', + ); + }); + + it('strips multiple internal tag blocks', () => { + expect(stripInternalTags('ahellob')).toBe('hello'); + }); + + it('returns empty string when input is only internal tags', () => { + expect(stripInternalTags('only this')).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('thinkingThe answer is 42')).toBe( + 'The answer is 42', + ); + }); +}); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index fbf1ed9..b03f5bd 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -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 `` 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 = `\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 `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } 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 body. + * + * Matches v1 format (src/v1/router.ts:10-18): `Y`. + * Requires BOTH sender and text — if only id is present the reply_to attribute + * on the parent 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${escapeXml(preview)}\n`; + const sender = replyTo.sender; + const text = replyTo.text; + if (!sender || !text) return ''; + return `\n ${escapeXml(text)}\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, '"'); } + +/** + * Strip `...` 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(/[\s\S]*?<\/internal>/g, '').trim(); +} diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 168808c..00e41bb 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -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 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 = { 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; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index cc26286..742de14 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -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(/[\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, diff --git a/container/agent-runner/src/timezone.test.ts b/container/agent-runner/src/timezone.test.ts new file mode 100644 index 0000000..a4539e9 --- /dev/null +++ b/container/agent-runner/src/timezone.test.ts @@ -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'); + }); +}); diff --git a/container/agent-runner/src/timezone.ts b/container/agent-runner/src/timezone.ts new file mode 100644 index 0000000..d9a2e1b --- /dev/null +++ b/container/agent-runner/src/timezone.ts @@ -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); +} diff --git a/src/modules/scheduling/recurrence.test.ts b/src/modules/scheduling/recurrence.test.ts new file mode 100644 index 0000000..a70d6c8 --- /dev/null +++ b/src/modules/scheduling/recurrence.test.ts @@ -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); + }); +}); diff --git a/src/modules/scheduling/recurrence.ts b/src/modules/scheduling/recurrence.ts index a8a2e5c..d521f95 100644 --- a/src/modules/scheduling/recurrence.ts +++ b/src/modules/scheduling/recurrence.ts @@ -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)}`;