- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module docs + ACTION-ITEMS rollup with decisions + timezone recreation spec). - container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness used to characterise Claude Agent SDK event/hook/stderr timing for the stuck-detection design in item 9. - src/channels/chat-sdk-bridge.ts: document the conversations Map staleness in a code comment; fix deferred to when dynamic group registration lands (ACTION-ITEMS item 17). No runtime behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
v1 Timezone + Formatting — Recreation Spec
Source commits
Parent of deletion: 86becf8^ = 27c52205f9fdeac0483600b2663f1c4d80aba45d
Deletion commit: 86becf8 (chore: delete v1 reference code)
Relevant v1 files at commit 27c5220 (v1^):
src/v1/router.ts— message formatting logic (escapeXml, formatMessages, stripInternalTags, formatOutbound)src/v1/timezone.ts— timezone utility functions (isValidTimezone, resolveTimezone, formatLocalTime)src/v1/config.ts— configuration and trigger patterns (buildTriggerPattern, getTriggerPattern, TIMEZONE resolution)src/v1/task-scheduler.ts— scheduled task timezone handling (computeNextRun with cron-parser)src/v1/types.ts— data structures (NewMessage interface)src/v1/formatting.test.ts— comprehensive test suite for all formatting behaviorsrc/v1/timezone.test.ts— timezone utility testssrc/v1/task-scheduler.test.ts— scheduler tests
1. Timestamp formatting on inbound messages
v1 behavior (exact)
Function: formatLocalTime() in src/v1/timezone.ts:26-36
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,
});
}
Input: UTC ISO 8601 timestamp (e.g., '2024-01-01T00:00:00.000Z') + timezone name (e.g., 'America/New_York')
Output format example:
- Input:
'2024-01-01T18:30:00.000Z'with timezone'America/New_York'(EST, UTC-5) - Output:
'1:30 PM'(with additional date components: month short name, day, year, hour, 2-digit minute, 12-hour format) - Full example output:
"Jan 1, 2024, 1:30 PM"(exact format depends on browser/Node locale)
Critical Details:
- Uses JavaScript's
Intl.DateTimeFormatAPI withen-USlocale - Format options:
{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true } - Handles invalid timezone gracefully by calling
resolveTimezone(timezone)which falls back to UTC - No external dependencies (no moment.js, date-fns, or day.js)
Where it's called:
src/v1/router.ts:11informatMessages()function to convert each message'sm.timestampto display time- The display time is then placed in the
time="..."attribute of the XML message element
Test coverage
From src/v1/formatting.test.ts:51-84:
-
Basic formatting with context header
- Input: Single message with timestamp
'2024-01-01T00:00:00.000Z', timezone'UTC' - Asserts:
result.toContain('Jan 1, 2024')and'<context timezone="UTC" />' - File:line:
src/v1/formatting.test.ts:51-56
- Input: Single message with timestamp
-
Timezone conversion to local time
- Input: Timestamp
'2024-01-01T18:30:00.000Z'with timezone'America/New_York'(EST) - Asserts: Result contains
'1:30'and'PM'(correct EST conversion, UTC-5) - File:line:
src/v1/formatting.test.ts:74-78
- Input: Timestamp
From src/v1/timezone.test.ts:10-30:
-
formatLocalTime with timezone conversion
- Input:
'2026-02-04T18:30:00.000Z'with'America/New_York' - Asserts: Contains
'1:30','PM','Feb','2026' - File:line:
src/v1/timezone.test.ts:10-16
- Input:
-
Multiple timezones comparison
- Input: Same UTC time with different timezones (
'America/New_York','Asia/Tokyo') - Asserts: NY shows
'8:00'(EDT, UTC-4 in summer), Tokyo shows'9:00'(UTC+9) - File:line:
src/v1/timezone.test.ts:18-26
- Input: Same UTC time with different timezones (
-
Invalid timezone fallback
- Input: Invalid timezone
'IST-2' - Asserts: Does not throw, formats as UTC (falls back)
- File:line:
src/v1/timezone.test.ts:28-33
- Input: Invalid timezone
2. Context timezone header
v1 behavior (exact)
Location: Prepended at the START of the formatted message block in src/v1/router.ts:20-22
Format:
<context timezone="<TIMEZONE_NAME>" />
Code:
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
What it includes:
- Only the timezone name (IANA identifier, e.g.,
'UTC','America/New_York') - NOT the current time (that's in each individual message's
time="..."attribute) - XML-escaped to prevent injection (via
escapeXml())
Per-message vs per-turn:
- The header appears once per call to
formatMessages(), which formats a batch of messages - The entire batch (header + all messages) is passed to the agent as a single unit
- The
timezoneparameter is passed in from the caller (src/v1/router.ts:9line signature)
Where it's wired:
src/v1/router.ts:9—formatMessages(messages: NewMessage[], timezone: string)accepts timezone as a parameter- This function is called from the channel message processing loop (inbound message handler)
- The caller supplies the
TIMEZONEconstant fromsrc/v1/config.ts:62
Test coverage
From src/v1/formatting.test.ts:51-56:
-
Context header is included in output
- Input: Any message list with timezone
'UTC' - Asserts:
result.toContain('<context timezone="UTC" />') - File:line:
src/v1/formatting.test.ts:51-56
- Input: Any message list with timezone
-
Context header with non-UTC timezone
- Input: Timezone
'America/New_York' - Asserts:
result.toContain('<context timezone="America/New_York" />') - File:line:
src/v1/formatting.test.ts:74-78
- Input: Timezone
-
Context header with empty message list
- Input: Empty array with timezone
'UTC' - Asserts:
result.toContain('<context timezone="UTC" />')even when no messages - File:line:
src/v1/formatting.test.ts:80-83
- Input: Empty array with timezone
3. Reply-to handling with message IDs
v1 behavior (exact)
Location: In the message formatting loop in src/v1/router.ts:10-18
Code:
const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : '';
const replySnippet =
m.reply_to_message_content && m.reply_to_sender_name
? `\n <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
: '';
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
Format of reply-to:
- Attribute:
reply_to="<MESSAGE_ID>"on the<message>tag (ifm.reply_to_message_idis present) - The ID is XML-escaped via
escapeXml() - Nested element:
<quoted_message from="<SENDER_NAME>"><MESSAGE_CONTENT></quoted_message>(if both sender and content are present) - Both sender name and content are XML-escaped
What it contains:
reply_to="<id>"attribute with the exact message ID fromm.reply_to_message_id- Sender name from
m.reply_to_sender_name - Original message content from
m.reply_to_message_content - No timestamp of the referenced message
Conditional rendering:
- If
m.reply_to_message_idis present: includereply_to="<id>"attribute - If
m.reply_to_message_idis present but content/sender missing: include attribute only, no<quoted_message>element - If only content and sender (no ID): only
<quoted_message>element, no attribute
Example output:
<message sender="Alice" time="Jan 1, 2024, 12:00 PM" reply_to="42">
<quoted_message from="Bob">Are you coming tonight?</quoted_message>
Yes, on my way!</message>
Test coverage
From src/v1/formatting.test.ts:96-139:
-
Reply with both ID and quoted content
- Input: Message with
reply_to_message_id: '42',reply_to_sender_name: 'Bob',reply_to_message_content: 'Are you coming tonight?', content:'Yes, on my way!' - Asserts:
result.toContain('reply_to="42"')result.toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>')result.toContain('Yes, on my way!</message>')
- File:line:
src/v1/formatting.test.ts:96-112
- Input: Message with
-
No reply context when missing
- Input: Message without reply fields
- Asserts:
result.not.toContain('reply_to')result.not.toContain('quoted_message')
- File:line:
src/v1/formatting.test.ts:114-119
-
ID present but content missing
- Input:
reply_to_message_id: '42',reply_to_sender_name: 'Bob', but NOreply_to_message_content - Asserts:
result.toContain('reply_to="42"')result.not.toContain('quoted_message')
- File:line:
src/v1/formatting.test.ts:121-130
- Input:
-
XML escape in reply context
- Input:
reply_to_message_id: '1',reply_to_sender_name: 'A & B',reply_to_message_content: '<script>alert("xss")</script>' - Asserts:
result.toContain('from="A & B"')result.toContain('<script>alert("xss")</script>')
- File:line:
src/v1/formatting.test.ts:131-139
- Input:
4. Internal tag stripping
v1 behavior (exact)
Function name: stripInternalTags() in src/v1/router.ts:25-27
Implementation:
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
Regex pattern: /<internal>[\s\S]*?<\/internal>/g
<internal>— literal opening tag[\s\S]*?— match any character (whitespace or non-whitespace) non-greedily<\/internal>— literal closing taggflag — global (all matches)
Post-processing: .trim() removes leading/trailing whitespace after all tags are stripped
Where it's called:
src/v1/router.ts:30informatOutbound()function- Called AFTER the tag removal to clean the output before returning
Used for: Stripping internal thinking/reasoning from outbound messages before sending to channel
Input/Output examples:
-
Single-line internal tag:
- Input:
'hello <internal>secret</internal> world' - Output:
'hello world'(then.trim()would be'hello world')
- Input:
-
Multi-line internal tags:
- Input:
'hello <internal>\nsecret\nstuff\n</internal> world' - Output:
'hello world'
- Input:
-
Multiple blocks:
- Input:
'<internal>a</internal>hello<internal>b</internal>' - Output:
'hello'
- Input:
-
Only internal content:
- Input:
'<internal>only this</internal>' - Output:
''(empty after trim)
- Input:
Test coverage
From src/v1/formatting.test.ts:163-181:
-
Single-line tag stripping
- Input:
'hello <internal>secret</internal> world' - Asserts: Result is
'hello world'(two spaces, then.trim()removes outer whitespace) - Expected (with trim):
'hello world' - File:line:
src/v1/formatting.test.ts:163-165
- Input:
-
Multi-line tag stripping
- Input:
'hello <internal>\nsecret\nstuff\n</internal> world' - Asserts: Result is
'hello world'(after trim) - File:line:
src/v1/formatting.test.ts:167-169
- Input:
-
Multiple internal blocks
- Input:
'<internal>a</internal>hello<internal>b</internal>' - Asserts: Result is
'hello' - File:line:
src/v1/formatting.test.ts:171-173
- Input:
-
Only internal content
- Input:
'<internal>only this</internal>' - Asserts: Result is
''(empty string) - File:line:
src/v1/formatting.test.ts:175-177
- Input:
From src/v1/formatting.test.ts:183-194:
-
formatOutbound with no internal tags
- Input:
'hello world' - Asserts: Result is
'hello world' - File:line:
src/v1/formatting.test.ts:183-185
- Input:
-
formatOutbound with all internal content
- Input:
'<internal>hidden</internal>' - Asserts: Result is
''(returns early after strip) - File:line:
src/v1/formatting.test.ts:187-189
- Input:
-
formatOutbound strips and returns remaining
- Input:
'<internal>thinking</internal>The answer is 42' - Asserts: Result is
'The answer is 42' - File:line:
src/v1/formatting.test.ts:191-194
- Input:
5. Timezone handling for scheduled tasks
v1 behavior (exact)
Location: src/v1/task-scheduler.ts:20-49
Key function: computeNextRun(task: ScheduledTask): string | null
Cron timezone handling:
if (task.schedule_type === 'cron') {
const interval = CronExpressionParser.parse(task.schedule_value, {
tz: TIMEZONE,
});
return interval.next().toISOString();
}
Critical details:
- Uses
cron-parserlibrary'sCronExpressionParser.parse()method - Passes timezone option as
{ tz: TIMEZONE }(e.g.,{ tz: 'America/New_York' }) TIMEZONEis imported fromsrc/v1/config.ts:62and resolved viaresolveConfigTimezone()- The cron expression is interpreted in the user's timezone, not UTC
- Example: cron
'0 9 * * *'withtz: 'America/New_York'means 9 AM ET every day
Interval task handling:
if (task.schedule_type === 'interval') {
const ms = parseInt(task.schedule_value, 10);
if (!ms || ms <= 0) {
logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value');
return new Date(now + 60_000).toISOString();
}
let next = new Date(task.next_run!).getTime() + ms;
while (next <= now) {
next += ms;
}
return new Date(next).toISOString();
}
Interval specifics:
- Intervals are timezone-agnostic (pure millisecond-based)
- Anchored to the task's
next_runtime to prevent cumulative drift - If intervals have been missed, the loop skips forward to land in the future while maintaining the original schedule grid
Once-only tasks:
if (task.schedule_type === 'once') return null;
MCP tool description:
- v1 did not expose cron task scheduling directly to the agent (it was a server-side feature)
- The scheduling was configured in group config files, not via agent tool calls
Test coverage
From src/v1/task-scheduler.test.ts:33-60:
-
computeNextRun returns null for once-tasks
- Input: Task with
schedule_type: 'once' - Asserts:
computeNextRun(task)returnsnull - File:line:
src/v1/task-scheduler.test.ts:40-49
- Input: Task with
-
Interval task anchoring to prevent drift
- Input: Task scheduled 2s ago with interval
60000(1 minute) - Asserts: Next run =
scheduledTime + 60s, notnow + 60s - Expected: Exact alignment to the scheduled time grid
- File:line:
src/v1/task-scheduler.test.ts:33-39
- Input: Task scheduled 2s ago with interval
-
Interval task catches up without infinite loop
- Input: Task with 10 missed intervals (missed by 10 * 60000ms)
- Asserts: Next run is in the future and aligned to original schedule grid
- File:line:
src/v1/task-scheduler.test.ts:51-60
6. Complete test inventory (formatting.test.ts)
All test cases from src/v1/formatting.test.ts (lines 1-254):
Block 1: escapeXml tests (lines 22-46)
| Test name | Input | Expected output |
|---|---|---|
| escapes ampersands | 'a & b' |
'a & b' |
| escapes less-than | 'a < b' |
'a < b' |
| escapes greater-than | 'a > b' |
'a > b' |
| escapes double quotes | '"hello"' |
'"hello"' |
| handles multiple special characters together | 'a & b < c > d "e"' |
'a & b < c > d "e"' |
| passes through strings with no special chars | 'hello world' |
'hello world' |
| handles empty string | '' |
'' |
Block 2: formatMessages tests (lines 48-159)
| Test name | Input | Key asserts |
|---|---|---|
| formats a single message as XML with context header (line 51) | Single message with timestamp '2024-01-01T00:00:00.000Z', TZ 'UTC' |
Contains '<context timezone="UTC" />', '<message sender="Alice"', '>hello</message>', 'Jan 1, 2024' |
| formats multiple messages (line 59) | 2 messages: Alice at 00:00, Bob at 01:00 | Contains both sender names and contents |
| escapes special characters in sender names (line 72) | Sender 'A & B <Co>' |
Contains 'sender="A & B <Co>"' |
| escapes special characters in content (line 79) | Content '<script>alert("xss")</script>' |
Contains escaped script tags '<script>...' |
| handles empty array (line 85) | Empty message list, TZ 'UTC' |
Contains header and '<messages>\n\n</messages>' |
| renders reply context as quoted_message element (line 96) | Message with reply_to_message_id: '42', reply_to_sender_name: 'Bob', reply_to_message_content: 'Are you coming tonight?' |
Contains 'reply_to="42"', '<quoted_message from="Bob">Are you coming tonight?</quoted_message>' |
| omits reply attributes when no reply context (line 114) | Message without reply fields | Does NOT contain 'reply_to' or 'quoted_message' |
| omits quoted_message when content is missing but id is present (line 121) | Message with reply_to_message_id: '42' but no reply_to_message_content |
Contains 'reply_to="42"' but NOT '<quoted_message' |
| escapes special characters in reply context (line 131) | Sender 'A & B', content '<script>alert("xss")</script>' |
Contains 'from="A & B"' and escaped script |
| converts timestamps to local time for given timezone (line 140) | Timestamp '2024-01-01T18:30:00.000Z' with TZ 'America/New_York' (EST, UTC-5) |
Contains '1:30', 'PM', header has 'America/New_York' |
Block 3: TRIGGER_PATTERN tests (lines 146-169)
| Test name | Input | Expected result |
|---|---|---|
| matches @name at start of message (line 152) | '@Andy hello' (assuming ASSISTANT_NAME='Andy') |
true |
| matches case-insensitively (line 156) | '@andy hello' or '@ANDY hello' |
true |
| does not match when not at start of message (line 160) | 'hello @Andy' |
false |
| does not match partial name like @NameExtra (word boundary) (line 164) | '@Andyextra hello' |
false |
| matches with word boundary before apostrophe (line 168) | '@Andy\'s thing' |
true |
| matches @name alone (end of string is a word boundary) (line 172) | '@Andy' |
true |
| matches with leading whitespace after trim (line 175) | ' @Andy hey' (after .trim()) |
true |
Block 4: getTriggerPattern tests (lines 177-196)
| Test name | Input | Expected behavior |
|---|---|---|
| uses the configured per-group trigger when provided (line 180) | getTriggerPattern('@Claw') |
Matches '@Claw hello', does NOT match '@Andy hello' |
| falls back to the default trigger when group trigger is missing (line 186) | getTriggerPattern(undefined) |
Matches default trigger '@Andy hello' |
| treats regex characters in custom triggers literally (line 192) | getTriggerPattern('@C.L.A.U.D.E') |
Matches literal dots, NOT wildcard (does NOT match '@CXLXAUXDXE') |
Block 5: stripInternalTags tests (lines 198-210)
| Test name | Input | Expected output |
|---|---|---|
| strips single-line internal tags (line 199) | 'hello <internal>secret</internal> world' |
'hello world' (then .trim() makes it 'hello world') |
| strips multi-line internal tags (line 203) | 'hello <internal>\nsecret\nstuff\n</internal> world' |
'hello world' |
| strips multiple internal tag blocks (line 207) | '<internal>a</internal>hello<internal>b</internal>' |
'hello' |
| returns empty string when text is only internal tags (line 211) | '<internal>only this</internal>' |
'' |
Block 6: formatOutbound tests (lines 213-226)
| Test name | Input | Expected output |
|---|---|---|
| returns text with internal tags stripped (line 214) | 'hello world' |
'hello world' |
| returns empty string when all text is internal (line 218) | '<internal>hidden</internal>' |
'' |
| strips internal tags from remaining text (line 222) | '<internal>thinking</internal>The answer is 42' |
'The answer is 42' |
Block 7: trigger gating (requiresTrigger interaction) tests (lines 228-254)
| Test name | Input | Expected result |
|---|---|---|
| main group always processes (no trigger needed) (line 239) | isMainGroup: true, message without trigger |
true |
| main group processes even with requiresTrigger=true (line 244) | isMainGroup: true, requiresTrigger: true, no trigger |
true |
| non-main group with requiresTrigger=undefined requires trigger (line 249) | isMainGroup: false, requiresTrigger: undefined, no trigger |
false |
| non-main group with requiresTrigger=true requires trigger (line 254) | isMainGroup: false, requiresTrigger: true, no trigger |
false |
| non-main group with requiresTrigger=true processes when trigger present (line 259) | isMainGroup: false, trigger in message |
true |
| non-main group uses per-group trigger instead of default (line 264) | isMainGroup: false, trigger: '@Claw', message '@Claw do something' |
true |
| non-main group does not process when only default trigger is present for custom-trigger group (line 269) | isMainGroup: false, trigger: '@Claw', message '@Andy do something' |
false |
| non-main group with requiresTrigger=false always processes (line 274) | isMainGroup: false, requiresTrigger: false, no trigger |
true |
v2 porting plan
For each of sections 1–5: the specific change to make in v2
1. Timestamp formatting
v2 file to modify: (Unknown — search for where v2 formats inbound messages to the agent)
Change needed:
- Find where v2 currently formats message timestamps for the agent
- Replace any custom date formatting with the v1 pattern:
- Call
new Date(timestamp).toLocaleString('en-US', { timeZone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })
- Call
- Ensure the timezone parameter is sourced from
config.TIMEZONE(or equivalent in v2)
Test to port: src/v1/formatting.test.ts:51-56 (basic formatting) and src/v1/formatting.test.ts:74-78 (timezone conversion)
2. Context timezone header
v2 file to modify: (Unknown — search for where v2 constructs the XML/prompt for inbound messages)
Change needed:
- Prepend
<context timezone="<TIMEZONE_NAME>" />\nto the formatted message block - The timezone should be the resolved IANA identifier (e.g.,
'UTC','America/New_York') - Ensure it's placed BEFORE the
<messages>element
Test to port: src/v1/formatting.test.ts:51-56 and src/v1/formatting.test.ts:80-83 (empty array still has header)
3. Reply-to with message ID
v2 file to modify: (Unknown — search for where v2 formats message metadata)
Change needed:
- If
message.reply_to_message_idis present, addreply_to="<ID>"attribute to the<message>element - If BOTH
message.reply_to_message_contentANDmessage.reply_to_sender_nameare present, include a nested<quoted_message from="<SENDER>"><CONTENT></quoted_message>element - XML-escape all three values (ID, sender name, content)
Test to port:
src/v1/formatting.test.ts:96-112(full reply context)src/v1/formatting.test.ts:121-130(ID only, no content)src/v1/formatting.test.ts:131-139(XML escaping in reply)
4. Internal tag stripping
v2 file to modify: (Unknown — search for where v2 processes outbound messages before sending)
Change needed:
- Apply the regex
/<internal>[\s\S]*?<\/internal>/gto strip all internal thinking/reasoning blocks - Call
.trim()on the result after stripping - Return empty string if result is empty after stripping
Test to port:
src/v1/formatting.test.ts:163-177(stripInternalTags)src/v1/formatting.test.ts:183-194(formatOutbound)
5. Scheduled task timezone handling
v2 file to modify: (Unknown — search for where v2 handles cron task scheduling)
Change needed:
- When parsing cron expressions, pass the timezone option to cron-parser:
const interval = CronExpressionParser.parse(cronExpression, { tz: TIMEZONE }); - For interval-based tasks, anchor to the original
next_runtime, notDate.now(), to prevent drift - Ensure the TIMEZONE constant is resolved at startup via a function like:
function resolveConfigTimezone(): string { const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { if (tz && isValidTimezone(tz)) return tz; } return 'UTC'; }
Test to port:
src/v1/task-scheduler.test.ts:33-39(interval anchoring)src/v1/task-scheduler.test.ts:40-49(once-task returns null)src/v1/task-scheduler.test.ts:51-60(interval catch-up)
Git references for verification
All code snippets above can be verified with:
git show 27c5220:src/v1/router.ts
git show 27c5220:src/v1/timezone.ts
git show 27c5220:src/v1/config.ts
git show 27c5220:src/v1/task-scheduler.ts
git show 27c5220:src/v1/types.ts
git show 27c5220:src/v1/formatting.test.ts
git show 27c5220:src/v1/timezone.test.ts
git show 27c5220:src/v1/task-scheduler.test.ts
Or from the deletion parent commit:
git show 86becf8^:src/v1/<filename>