- 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>
571 lines
24 KiB
Markdown
571 lines
24 KiB
Markdown
# 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 behavior
|
||
- `src/v1/timezone.test.ts` — timezone utility tests
|
||
- `src/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`
|
||
|
||
```typescript
|
||
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.DateTimeFormat` API with `en-US` locale
|
||
- 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:11` in `formatMessages()` function to convert each message's `m.timestamp` to 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`:
|
||
|
||
1. **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`
|
||
|
||
2. **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`
|
||
|
||
From `src/v1/timezone.test.ts:10-30`:
|
||
|
||
3. **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`
|
||
|
||
4. **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`
|
||
|
||
5. **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`
|
||
|
||
---
|
||
|
||
## 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**:
|
||
```xml
|
||
<context timezone="<TIMEZONE_NAME>" />
|
||
```
|
||
|
||
**Code**:
|
||
```typescript
|
||
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 `timezone` parameter is passed in from the caller (`src/v1/router.ts:9` line 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 `TIMEZONE` constant from `src/v1/config.ts:62`
|
||
|
||
### Test coverage
|
||
|
||
From `src/v1/formatting.test.ts:51-56`:
|
||
|
||
1. **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`
|
||
|
||
2. **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`
|
||
|
||
3. **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`
|
||
|
||
---
|
||
|
||
## 3. Reply-to handling with message IDs
|
||
|
||
### v1 behavior (exact)
|
||
|
||
**Location**: In the message formatting loop in `src/v1/router.ts:10-18`
|
||
|
||
**Code**:
|
||
```typescript
|
||
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 (if `m.reply_to_message_id` is 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 from `m.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**:
|
||
1. If `m.reply_to_message_id` is present: include `reply_to="<id>"` attribute
|
||
2. If `m.reply_to_message_id` is present but content/sender missing: include attribute only, no `<quoted_message>` element
|
||
3. If only content and sender (no ID): only `<quoted_message>` element, no attribute
|
||
|
||
**Example output**:
|
||
```xml
|
||
<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`:
|
||
|
||
1. **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`
|
||
|
||
2. **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`
|
||
|
||
3. **ID present but content missing**
|
||
- Input: `reply_to_message_id: '42'`, `reply_to_sender_name: 'Bob'`, but NO `reply_to_message_content`
|
||
- Asserts:
|
||
- `result.toContain('reply_to="42"')`
|
||
- `result.not.toContain('quoted_message')`
|
||
- File:line: `src/v1/formatting.test.ts:121-130`
|
||
|
||
4. **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`
|
||
|
||
---
|
||
|
||
## 4. Internal tag stripping
|
||
|
||
### v1 behavior (exact)
|
||
|
||
**Function name**: `stripInternalTags()` in `src/v1/router.ts:25-27`
|
||
|
||
**Implementation**:
|
||
```typescript
|
||
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 tag
|
||
- `g` flag — global (all matches)
|
||
|
||
**Post-processing**: `.trim()` removes leading/trailing whitespace after all tags are stripped
|
||
|
||
**Where it's called**:
|
||
- `src/v1/router.ts:30` in `formatOutbound()` 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**:
|
||
|
||
1. Single-line internal tag:
|
||
- Input: `'hello <internal>secret</internal> world'`
|
||
- Output: `'hello world'` (then `.trim()` would be `'hello world'`)
|
||
|
||
2. Multi-line internal tags:
|
||
- Input: `'hello <internal>\nsecret\nstuff\n</internal> world'`
|
||
- Output: `'hello world'`
|
||
|
||
3. Multiple blocks:
|
||
- Input: `'<internal>a</internal>hello<internal>b</internal>'`
|
||
- Output: `'hello'`
|
||
|
||
4. Only internal content:
|
||
- Input: `'<internal>only this</internal>'`
|
||
- Output: `''` (empty after trim)
|
||
|
||
### Test coverage
|
||
|
||
From `src/v1/formatting.test.ts:163-181`:
|
||
|
||
1. **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`
|
||
|
||
2. **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`
|
||
|
||
3. **Multiple internal blocks**
|
||
- Input: `'<internal>a</internal>hello<internal>b</internal>'`
|
||
- Asserts: Result is `'hello'`
|
||
- File:line: `src/v1/formatting.test.ts:171-173`
|
||
|
||
4. **Only internal content**
|
||
- Input: `'<internal>only this</internal>'`
|
||
- Asserts: Result is `''` (empty string)
|
||
- File:line: `src/v1/formatting.test.ts:175-177`
|
||
|
||
From `src/v1/formatting.test.ts:183-194`:
|
||
|
||
5. **formatOutbound with no internal tags**
|
||
- Input: `'hello world'`
|
||
- Asserts: Result is `'hello world'`
|
||
- File:line: `src/v1/formatting.test.ts:183-185`
|
||
|
||
6. **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`
|
||
|
||
7. **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`
|
||
|
||
---
|
||
|
||
## 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**:
|
||
```typescript
|
||
if (task.schedule_type === 'cron') {
|
||
const interval = CronExpressionParser.parse(task.schedule_value, {
|
||
tz: TIMEZONE,
|
||
});
|
||
return interval.next().toISOString();
|
||
}
|
||
```
|
||
|
||
**Critical details**:
|
||
- Uses `cron-parser` library's `CronExpressionParser.parse()` method
|
||
- Passes timezone option as `{ tz: TIMEZONE }` (e.g., `{ tz: 'America/New_York' }`)
|
||
- `TIMEZONE` is imported from `src/v1/config.ts:62` and resolved via `resolveConfigTimezone()`
|
||
- The cron expression is interpreted in the **user's timezone**, not UTC
|
||
- Example: cron `'0 9 * * *'` with `tz: 'America/New_York'` means 9 AM ET every day
|
||
|
||
**Interval task handling**:
|
||
```typescript
|
||
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_run` time 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**:
|
||
```typescript
|
||
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`:
|
||
|
||
1. **computeNextRun returns null for once-tasks**
|
||
- Input: Task with `schedule_type: 'once'`
|
||
- Asserts: `computeNextRun(task)` returns `null`
|
||
- File:line: `src/v1/task-scheduler.test.ts:40-49`
|
||
|
||
2. **Interval task anchoring to prevent drift**
|
||
- Input: Task scheduled 2s ago with interval `60000` (1 minute)
|
||
- Asserts: Next run = `scheduledTime + 60s`, not `now + 60s`
|
||
- Expected: Exact alignment to the scheduled time grid
|
||
- File:line: `src/v1/task-scheduler.test.ts:33-39`
|
||
|
||
3. **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**:
|
||
1. Find where v2 currently formats message timestamps for the agent
|
||
2. 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 })`
|
||
3. 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**:
|
||
1. Prepend `<context timezone="<TIMEZONE_NAME>" />\n` to the formatted message block
|
||
2. The timezone should be the resolved IANA identifier (e.g., `'UTC'`, `'America/New_York'`)
|
||
3. 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**:
|
||
1. If `message.reply_to_message_id` is present, add ` reply_to="<ID>"` attribute to the `<message>` element
|
||
2. If BOTH `message.reply_to_message_content` AND `message.reply_to_sender_name` are present, include a nested `<quoted_message from="<SENDER>"><CONTENT></quoted_message>` element
|
||
3. 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**:
|
||
1. Apply the regex `/<internal>[\s\S]*?<\/internal>/g` to strip all internal thinking/reasoning blocks
|
||
2. Call `.trim()` on the result after stripping
|
||
3. 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**:
|
||
1. When parsing cron expressions, pass the timezone option to cron-parser:
|
||
```typescript
|
||
const interval = CronExpressionParser.parse(cronExpression, { tz: TIMEZONE });
|
||
```
|
||
2. For interval-based tasks, anchor to the original `next_run` time, not `Date.now()`, to prevent drift
|
||
3. Ensure the TIMEZONE constant is resolved at startup via a function like:
|
||
```typescript
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
git show 86becf8^:src/v1/<filename>
|
||
```
|