diff --git a/backend/src/agents/main_agent/core/system_prompt_builder.py b/backend/src/agents/main_agent/core/system_prompt_builder.py index 035b28a88..ff48a8524 100644 --- a/backend/src/agents/main_agent/core/system_prompt_builder.py +++ b/backend/src/agents/main_agent/core/system_prompt_builder.py @@ -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}") diff --git a/backend/src/agents/main_agent/utils/timezone.py b/backend/src/agents/main_agent/utils/timezone.py index 9b6ef363b..d454891ba 100644 --- a/backend/src/agents/main_agent/utils/timezone.py +++ b/backend/src/agents/main_agent/utils/timezone.py @@ -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: @@ -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") diff --git a/backend/tests/agents/main_agent/core/test_system_prompt_builder.py b/backend/tests/agents/main_agent/core/test_system_prompt_builder.py index c08dafd9e..cb579b107 100644 --- a/backend/tests/agents/main_agent/core/test_system_prompt_builder.py +++ b/backend/tests/agents/main_agent/core/test_system_prompt_builder.py @@ -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() @@ -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 @@ -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) diff --git a/backend/tests/agents/main_agent/utils/test_timezone.py b/backend/tests/agents/main_agent/utils/test_timezone.py index 2ba49e4a9..741906914 100644 --- a/backend/tests/agents/main_agent/utils/test_timezone.py +++ b/backend/tests/agents/main_agent/utils/test_timezone.py @@ -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() @@ -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: @@ -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.""" @@ -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" diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/docx-viewer.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/docx-viewer.component.spec.ts index d52928abd..a36965900 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/docx-viewer.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/docx-viewer.component.spec.ts @@ -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'; diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts index 74a01cbc2..d18377112 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts @@ -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, @@ -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();