From 9f246cb7a288ba9351db23193efe763bbe5a77db Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Tue, 15 Sep 2026 19:58:26 -0600 Subject: [PATCH 1/2] fix(agent): drop the hour from the system-prompt date line to keep the prompt-cache prefix day-stable get_current_date_pacific() rendered "YYYY-MM-DD (Weekday) HH:00 TZ" and SystemPromptBuilder puts it at the tail of the system prompt, which is the head of the Bedrock prompt-cache prefix. Every Pacific hour boundary therefore flipped systemPromptHash and re-wrote the whole cached prefix for every active session. The 2026-09-15 prod cost audit measured 23 distinct systemPromptHash values in one 85-call session and attributed ~2.6% of September cache-write spend to this. The line now renders date, weekday and timezone only, so it is byte-stable for a whole Pacific day. Nothing in the prompt, skills, local tools or the BFF reads the hour (grepped for the helper, the %H:00 format and "current time"-style language), so no flow needed the hour moved into the user turn. Function name and signature are unchanged; the docstring and a comment at the render site record why the hour must stay out of the prefix. Tests: test_timezone.py gets the new regex plus frozen-clock tests that the string is identical across all hours of a day, flips at Pacific midnight, and uses the Pacific (not UTC) date; test_system_prompt_builder.py's mocked return values lose the hour. test_bedrock_cache_points.py is unchanged and still passes; the full tests/agents/main_agent suite is green. Co-Authored-By: Claude Fable 5.1 --- .../main_agent/core/system_prompt_builder.py | 2 + .../src/agents/main_agent/utils/timezone.py | 21 ++++-- .../core/test_system_prompt_builder.py | 14 ++-- .../agents/main_agent/utils/test_timezone.py | 70 +++++++++++++++---- 4 files changed, 82 insertions(+), 25 deletions(-) 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" From 442e574cf6a2624e16816032dd1879fc7c9d5c0c Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Tue, 15 Sep 2026 22:55:31 -0600 Subject: [PATCH 2/2] test(spa): reset DocxViewerComponent's memoized import between docx specs CI's "Test frontend (coverage build)" failed twice on this PR, which touches no frontend file, in file-preview-panel.component.spec.ts: "keeps download reachable when the document cannot be rendered" received a fully rendered document instead of the failure message. DocxViewerComponent memoizes its dynamic import('docx-preview') on a private static field, and @angular/build runs vitest with isolate: false, so every spec file in a worker shares one module registry and one DocxViewerComponent class. When docx-viewer.component.spec.ts runs first in the worker it pins its own mocked module into that field; the panel spec's renderAsync.mockRejectedValue() then programs a vi.fn the component never calls. The received text ("QuarterRev Q1100 Q2120") is the viewer spec's last table mock, byte for byte. Both specs now null the memo in beforeEach. Reproduced and verified with a single-worker, alphabetically ordered runner config (viewer spec before panel spec), then the full coverage build: 257 files / 3212 tests pass. Develop's exact frontend tree was never exercised by this job: CI runs only on PRs, the docx specs landed in 76e022a3, and later PRs that passed all add or change spec files, which reshuffles worker assignment. Co-Authored-By: Claude Fable 5.1 --- .../components/file-preview/docx-viewer.component.spec.ts | 4 ++++ .../file-preview/file-preview-panel.component.spec.ts | 5 +++++ 2 files changed, 9 insertions(+) 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();