Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/agents/main_agent/core/system_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ def build(self, include_date: bool = True) -> str:
str: Complete system prompt
"""
if include_date:
# This line sits in the Bedrock prompt-cache prefix, so it must be
# byte-stable within a day: date + weekday + timezone only, no hour.
current_date = get_current_date_pacific()
prompt = f"{self.base_prompt}\n\nCurrent date: {current_date}"
logger.info(f"Built system prompt with current date: {current_date}")
Expand Down
21 changes: 16 additions & 5 deletions backend/src/agents/main_agent/utils/timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,21 @@

def get_current_date_pacific() -> str:
"""
Get current date and hour in US Pacific timezone (America/Los_Angeles)
Get the current calendar date in US Pacific timezone (America/Los_Angeles).

The result is rendered into the system prompt, which is the head of the
Bedrock prompt-cache prefix (see the prompt-cache contract in CLAUDE.md).
It therefore deliberately contains **no hour**: the string is byte-stable
for a whole Pacific day, so the cached prefix is re-written once per day
instead of at every hour boundary. Earlier versions appended ``HH:00`` and
a 2026-09 prod cost audit attributed ~2.6% of cache-write spend to the
resulting hourly ``systemPromptHash`` flips. If a flow ever needs the
time of day, put it in the user turn (or another turn-scoped message),
never back in this prefix.

Returns:
str: Formatted date string with timezone (e.g., "2024-01-15 (Monday) 14:00 PST")
str: Date, weekday and timezone abbreviation
(e.g., "2024-01-15 (Monday) PST")
"""
try:
if TIMEZONE_AVAILABLE:
Expand All @@ -44,12 +55,12 @@ def get_current_date_pacific() -> str:
# Get timezone abbreviation (PST/PDT)
tz_abbr = now.strftime("%Z")

return now.strftime(f"%Y-%m-%d (%A) %H:00 {tz_abbr}")
return now.strftime(f"%Y-%m-%d (%A) {tz_abbr}")
else:
# Fallback to UTC if no timezone library available
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d (%A) %H:00 UTC")
return now.strftime("%Y-%m-%d (%A) UTC")
except Exception as e:
logger.warning(f"Failed to get Pacific time: {e}, using UTC")
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d (%A) %H:00 UTC")
return now.strftime("%Y-%m-%d (%A) UTC")
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ class TestBuildWithDateTrue:

@patch(
"agents.main_agent.core.system_prompt_builder.get_current_date_pacific",
return_value="2024-06-15 (Saturday) 10:00 PDT",
return_value="2024-06-15 (Saturday) PDT",
)
def test_appends_current_date_line(self, mock_date):
builder = SystemPromptBuilder()
result = builder.build(include_date=True)

assert result.endswith("Current date: 2024-06-15 (Saturday) 10:00 PDT")
assert result.endswith("Current date: 2024-06-15 (Saturday) PDT")

@patch(
"agents.main_agent.core.system_prompt_builder.get_current_date_pacific",
return_value="2024-06-15 (Saturday) 10:00 PDT",
return_value="2024-06-15 (Saturday) PDT",
)
def test_includes_base_prompt(self, mock_date):
builder = SystemPromptBuilder()
Expand All @@ -52,13 +52,13 @@ def test_includes_base_prompt(self, mock_date):

@patch(
"agents.main_agent.core.system_prompt_builder.get_current_date_pacific",
return_value="2024-01-01 (Monday) 08:00 PST",
return_value="2024-01-01 (Monday) PST",
)
def test_date_separated_by_blank_line(self, mock_date):
builder = SystemPromptBuilder()
result = builder.build(include_date=True)

expected = f"{DEFAULT_SYSTEM_PROMPT}\n\nCurrent date: 2024-01-01 (Monday) 08:00 PST"
expected = f"{DEFAULT_SYSTEM_PROMPT}\n\nCurrent date: 2024-01-01 (Monday) PST"
assert result == expected


Expand Down Expand Up @@ -102,14 +102,14 @@ def test_uses_custom_prompt(self):

@patch(
"agents.main_agent.core.system_prompt_builder.get_current_date_pacific",
return_value="2024-03-20 (Wednesday) 15:00 PDT",
return_value="2024-03-20 (Wednesday) PDT",
)
def test_build_with_date_uses_custom_prompt(self, mock_date):
custom = "Custom prompt."
builder = SystemPromptBuilder(base_prompt=custom)
result = builder.build(include_date=True)

assert result == "Custom prompt.\n\nCurrent date: 2024-03-20 (Wednesday) 15:00 PDT"
assert result == "Custom prompt.\n\nCurrent date: 2024-03-20 (Wednesday) PDT"

def test_none_base_prompt_falls_back_to_default(self):
builder = SystemPromptBuilder(base_prompt=None)
Expand Down
70 changes: 57 additions & 13 deletions backend/tests/agents/main_agent/utils/test_timezone.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
"""
Tests for timezone utilities.
Requirements: 21.1–21.3

The rendered string is part of the Bedrock prompt-cache prefix, so it must be
byte-stable within a Pacific day: date, weekday and timezone only — no hour.
"""

import re
from datetime import datetime
from unittest.mock import patch

import pytest
from zoneinfo import ZoneInfo

from agents.main_agent.utils.timezone import get_current_date_pacific


# Regex for "YYYY-MM-DD (DayName) HH:00 TZ"
# Regex for "YYYY-MM-DD (DayName) TZ"
DATE_FORMAT_RE = re.compile(
r"^\d{4}-\d{2}-\d{2} " # YYYY-MM-DD
r"\([A-Z][a-z]+\) " # (DayName)
r"\d{2}:00 " # HH:00
r"[A-Z]{3,4}$" # TZ abbreviation
)

VALID_DAYS = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}

PACIFIC = ZoneInfo("America/Los_Angeles")


class _FrozenDatetime(datetime):
"""datetime subclass whose now() returns a fixed instant (in the requested tz)."""

frozen: datetime

@classmethod
def now(cls, tz=None):
return cls.frozen.astimezone(tz) if tz else cls.frozen


def _at(instant: datetime) -> str:
"""Call get_current_date_pacific() as if it were `instant` right now."""
_FrozenDatetime.frozen = instant
with patch("agents.main_agent.utils.timezone.datetime", _FrozenDatetime):
return get_current_date_pacific()


class TestGetCurrentDatePacific:
"""Req 21.1: Verify format matches 'YYYY-MM-DD (DayName) HH:00 TZ'."""
"""Req 21.1: Verify format matches 'YYYY-MM-DD (DayName) TZ'."""

def test_format_matches_expected_pattern(self):
result = get_current_date_pacific()
Expand All @@ -34,12 +55,32 @@ def test_contains_valid_day_name(self):
day = re.search(r"\((\w+)\)", result).group(1)
assert day in VALID_DAYS, f"Day '{day}' is not a valid day name"

def test_hour_is_zero_padded_with_00_minutes(self):
def test_has_no_time_of_day_component(self):
# Prompt-cache contract: the line must not change at hour boundaries.
result = get_current_date_pacific()
hour_match = re.search(r"(\d{2}):00", result)
assert hour_match is not None
hour = int(hour_match.group(1))
assert 0 <= hour <= 23
assert not re.search(r"\d{1,2}:\d{2}", result), f"Unexpected time-of-day in '{result}'"
assert len(result.split()) == 3


class TestDayStability:
"""The rendered string must be identical for every hour of the same Pacific day
and change only when the Pacific calendar date changes."""

def test_identical_across_every_hour_of_a_day(self):
base = datetime(2026, 9, 15, 0, 0, tzinfo=PACIFIC)
rendered = {_at(base.replace(hour=h, minute=m)) for h in range(24) for m in (0, 59)}
assert rendered == {"2026-09-15 (Tuesday) PDT"}, rendered

def test_changes_at_pacific_midnight(self):
before = _at(datetime(2026, 9, 15, 23, 59, tzinfo=PACIFIC))
after = _at(datetime(2026, 9, 16, 0, 0, tzinfo=PACIFIC))
assert before == "2026-09-15 (Tuesday) PDT"
assert after == "2026-09-16 (Wednesday) PDT"

def test_uses_pacific_date_not_utc_date(self):
# 03:00 UTC on the 16th is still 20:00 PDT on the 15th.
utc_instant = datetime(2026, 9, 16, 3, 0, tzinfo=ZoneInfo("UTC"))
assert _at(utc_instant) == "2026-09-15 (Tuesday) PDT"


class TestTimezoneAbbreviation:
Expand All @@ -50,6 +91,9 @@ def test_timezone_is_pst_or_pdt(self):
tz = result.split()[-1]
assert tz in ("PST", "PDT"), f"Timezone '{tz}' is not PST or PDT"

def test_standard_time_renders_pst(self):
assert _at(datetime(2026, 1, 15, 12, 0, tzinfo=PACIFIC)) == "2026-01-15 (Thursday) PST"


class TestUTCFallback:
"""Req 21.3: Verify UTC fallback when timezone libraries unavailable."""
Expand All @@ -63,7 +107,7 @@ def test_falls_back_to_utc_when_timezone_unavailable(self):
def test_utc_fallback_format_matches(self):
with patch("agents.main_agent.utils.timezone.TIMEZONE_AVAILABLE", False):
result = get_current_date_pacific()
# Should still have YYYY-MM-DD (DayName) HH:00 UTC
# Should still have YYYY-MM-DD (DayName) UTC — and no hour
parts = result.split()
assert len(parts) == 4
assert parts[3] == "UTC"
assert len(parts) == 3
assert parts[2] == "UTC"
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ describe('DocxViewerComponent', () => {

beforeEach(async () => {
renderAsync.mockReset();
// The component memoizes its dynamic import('docx-preview') on a static
// field, and the builder runs vitest with isolate: false, so a sibling spec
// that rendered first would pin *its* mocked module for this file too.
(DocxViewerComponent as unknown as { libraryPromise: unknown }).libraryPromise = null;
renderAsync.mockImplementation((_data, host: HTMLElement) => {
const wrapper = document.createElement('div');
wrapper.className = 'docx-wrapper';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FilePreviewPanelComponent } from './file-preview-panel.component';
import { DocxViewerComponent } from './docx-viewer.component';
import { FilePreviewStateService } from '../../../../services/file-preview/file-preview-state.service';
import {
FilePreviewError,
Expand All @@ -22,6 +23,10 @@ describe('FilePreviewPanelComponent', () => {

beforeEach(async () => {
renderAsync.mockReset();
// The component memoizes its dynamic import('docx-preview') on a static
// field, and the builder runs vitest with isolate: false, so a sibling spec
// that rendered first would pin *its* mocked module for this file too.
(DocxViewerComponent as unknown as { libraryPromise: unknown }).libraryPromise = null;
renderAsync.mockImplementation((_data, host: HTMLElement) => {
host.appendChild(document.createElement('section'));
return Promise.resolve();
Expand Down