diff --git a/src/context_system/prompt_assembly.py b/src/context_system/prompt_assembly.py index 06e583a5..0ab4bf68 100644 --- a/src/context_system/prompt_assembly.py +++ b/src/context_system/prompt_assembly.py @@ -687,11 +687,11 @@ def build_full_system_prompt_blocks( # WI-2.2: TTL selector. ``should_1h_cache_ttl(query_source)`` returns # True only when (a) the user is 1h-eligible per the latched # evaluation in cache_state.evaluate_prompt_cache_1h_eligibility, AND - # (b) the query source is in the GrowthBook-populated allowlist. + # (b) the query source is in the configured allowlist (#285: + # settings.prompt_cache_1h_sources / CLAWCODEX_PROMPT_CACHE_1H_SOURCES, + # installed at session start by initialize_prompt_cache_state). # When either condition is False, fall back to "5m" — the safe-default - # TTL that Phase 1 already engaged. The allowlist is empty by default - # (no GrowthBook port yet), so this defaults to "5m" universally until - # a future WI populates it. + # TTL that Phase 1 already engaged. from src.state.cache_state import should_1h_cache_ttl, should_use_global_cache_scope ttl = "1h" if should_1h_cache_ttl(query_source) else "5m" diff --git a/src/init.py b/src/init.py index 9cda57a8..1ab73207 100644 --- a/src/init.py +++ b/src/init.py @@ -177,6 +177,18 @@ def run_pre_action(args: object) -> None: # workspace through. set_session_trust_accepted(False) + # #285: latch the 1h prompt-cache eligibility decision and install + # the configured query-source allowlist — without this the latch + # stays None and 1h caching is permanently dormant. Fail-soft. + try: + from src.state.session_start import initialize_prompt_cache_state + + initialize_prompt_cache_state() + except Exception: + logging.getLogger(__name__).debug( + "prompt-cache state init failed", exc_info=True + ) + profile_checkpoint("pre_action_end") diff --git a/src/settings/types.py b/src/settings/types.py index 7d4b005b..899776a1 100644 --- a/src/settings/types.py +++ b/src/settings/types.py @@ -157,6 +157,14 @@ class SettingsSchema: # provider-scoped in a multi-provider config). model_provider: str = "" + # Query sources eligible for 1h prompt-cache TTL (#285) — the + # config-backed replacement for the TS GrowthBook allowlist + # (e.g. ["repl_main_thread"]). Empty = 1h caching dormant. + # CLAWCODEX_PROMPT_CACHE_1H_SOURCES (comma-separated) overrides when + # SET, including set-but-empty as a kill switch. Like every list + # setting, a more specific config layer REPLACES (not extends) this. + prompt_cache_1h_sources: list[str] = field(default_factory=list) + # Disable dynamic workflows (also honored via CLAUDE_CODE_DISABLE_WORKFLOWS # and the camelCase ``disableWorkflows`` JSON key). See src/workflow/gating.py. disable_workflows: bool = False diff --git a/src/state/cache_state.py b/src/state/cache_state.py index 5e75fb8e..3ab500d4 100644 --- a/src/state/cache_state.py +++ b/src/state/cache_state.py @@ -11,18 +11,24 @@ Sacrificing mid-session toggleability buys cache stability worth far more in dollars per turn. -Per the Phase 2 audit (M9-resolved): - * ``prompt_cache_1h_eligible`` — wired here; consumed by WI-2.2's +Per the Phase 2 audit (M9-resolved; #285 wired the 1h path): + * ``prompt_cache_1h_eligible`` — latched at session start by + ``src/state/session_start.initialize_prompt_cache_state`` (called + from ``init.pre_action``); consumed by WI-2.2's ``should_1h_cache_ttl`` selector. + * ``prompt_cache_1h_allowlist`` — populated at the same session-start + site from ``settings.prompt_cache_1h_sources`` / + ``CLAWCODEX_PROMPT_CACHE_1H_SOURCES`` (the config-backed, + non-GrowthBook channel — #285). * ``fast_mode_header_latched`` — wired by ``src/utils/fast_mode.py`` on first true result of ``is_fast_mode_enabled()``. - * ``afk_mode_header_latched`` — DEAD STORE today (no AFK toggle in - Python TUI yet). Future TUI WI must add the trigger. - * ``cache_editing_header_latched`` — DEAD STORE today (cache-editing is - a TS GrowthBook treatment with no Python equivalent yet). - * ``thinking_clear_latched`` — DEAD STORE today (thinking-mode-flip event - is not exposed by ``src/utils/effort.py`` in the form needed for this - latch). Future WI must surface the event. + * ``afk_mode_header_latched`` — DEAD STORE: its source feature (the TUI + AFK toggle) is not built. Wire when that feature lands (#285 audit). + * ``cache_editing_header_latched`` — DEAD STORE: cache-editing is a TS + GrowthBook treatment with no Python equivalent. Wire with that port. + * ``thinking_clear_latched`` — DEAD STORE: the port has no extended- + thinking request parameter, so a thinking-flip-after-cache-miss event + cannot exist yet. Wire when thinking lands. """ from __future__ import annotations @@ -41,6 +47,7 @@ "get_prompt_cache_1h_allowlist", "get_prompt_cache_1h_eligible", "is_first_party_provider", + "populate_prompt_cache_1h_allowlist", "reset_for_test_only", "should_1h_cache_ttl", "should_use_global_cache_scope", @@ -78,10 +85,11 @@ class BetaHeaderLatches: # ``prompt_cache_1h_eligible`` is True, the per-call decision still # requires the ``query_source`` to appear in this list — mirrors TS # GrowthBook config at ``services/api/claude.ts:430-438``. - # Default empty list = no source emits 1h. Population of this list is - # left to a future WI that ports the GrowthBook integration; for now - # the allowlist remains empty and 1h caching is dormant (5m caching - # still works because Phase 1 already engaged it). + # Default empty list = no source emits 1h. Populated once per session + # by ``populate_prompt_cache_1h_allowlist`` from configuration + # (settings.prompt_cache_1h_sources / CLAWCODEX_PROMPT_CACHE_1H_SOURCES + # — the non-GrowthBook channel, #285); unconfigured installs stay + # dormant (5m caching still works from Phase 1). prompt_cache_1h_allowlist: list[str] = field(default_factory=list) # Toggle latches. Set on first toggle event; never reset. @@ -133,15 +141,32 @@ def get_prompt_cache_1h_eligible() -> bool | None: def get_prompt_cache_1h_allowlist() -> list[str]: """Read the 1h-cache query-source allowlist. - Returns a copy to discourage caller mutation. The allowlist is - populated by future GrowthBook-port work (currently always empty in - the open build). Plain getter for parity with TS - ``getPromptCache1hAllowlist`` (``bootstrap/state.ts:1579``). **No - setter is exposed**. + Returns a copy to discourage caller mutation. Populated once per + session from configuration via + ``populate_prompt_cache_1h_allowlist`` (#285 — the non-GrowthBook + config channel). Plain getter for parity with TS + ``getPromptCache1hAllowlist`` (``bootstrap/state.ts:1579``). """ return list(_LATCHES.prompt_cache_1h_allowlist) +def populate_prompt_cache_1h_allowlist(sources: list[str]) -> bool: + """Populate the 1h-cache allowlist ONCE per session (#285). + + The config-backed replacement for the TS GrowthBook channel: the + session-start wiring reads the configured query sources and installs + them here. Sticky like every other field in this module — a + non-empty allowlist is never replaced mid-session (a flip would bust + the cached prompt prefix this module exists to protect). Returns + True when the list was installed. + """ + cleaned = [s.strip() for s in sources if isinstance(s, str) and s.strip()] + if not cleaned or _LATCHES.prompt_cache_1h_allowlist: + return False + _LATCHES.prompt_cache_1h_allowlist = cleaned + return True + + def evaluate_prompt_cache_1h_eligibility( *, is_ant_user: bool, @@ -165,16 +190,14 @@ def evaluate_prompt_cache_1h_eligibility( keeps 1h caching dormant. When the porting WI for these inputs lands, 1h caching activates without requiring code changes here. - **Status (Phase 2):** this primitive is implemented but has NO - production caller today. ``grep -rn "evaluate_prompt_cache_1h_eligibility" - src/`` returns only the definition. The 1h cache path is therefore - end-to-end dormant: ``prompt_cache_1h_eligible`` stays at ``None``, - ``should_1h_cache_ttl`` always returns False, every cache_control - emits ``ttl: '5m'``. Activating 1h requires (a) porting the user-type - /subscription/overage signals, (b) calling this function with real - inputs at session start, AND (c) populating - ``prompt_cache_1h_allowlist`` from a Python-equivalent of the TS - GrowthBook config. All three are deferred to a future WI. + **Status (#285 — wired):** called once per session via + ``src/state/session_start.initialize_prompt_cache_state`` (from + ``init.pre_action``, env-signal backed) and lazily from + ``should_1h_cache_ttl`` when the latch is unevaluated (SDK paths + that skip pre_action; a /clear that reset the latches). 1h engages + when the eligibility signals AND a configured allowlist + (``populate_prompt_cache_1h_allowlist``) are both present; + otherwise every cache_control stays at ``ttl: '5m'``. """ latches = get_beta_header_latches() if latches.prompt_cache_1h_eligible is None: @@ -191,12 +214,24 @@ def should_1h_cache_ttl(query_source: str) -> bool: 1. ``prompt_cache_1h_eligible`` is latched True (the user is eligible). 2. ``query_source`` is in the allowlist (this specific call is eligible). - The allowlist is empty by default — until a future WI populates it - from configuration, every call defaults to ``ttl: '5m'``. This is the - safe-default behavior: 5m caching is already engaged from Phase 1; 1h - is an opt-in extension for sessions that cross 5-minute idle gaps. + Unconfigured installs default every call to ``ttl: '5m'`` — the + safe behavior already engaged in Phase 1; 1h is an opt-in extension + (#285: settings.prompt_cache_1h_sources / the env override) for + sessions that cross 5-minute idle gaps. """ latches = get_beta_header_latches() + if latches.prompt_cache_1h_eligible is None: + # Lazy (re-)initialization — TS evaluates at the consumer + # (claude.ts:420-425). Covers SDK paths that never ran + # init.pre_action AND a /clear / /compact that reset the latch + # singleton (without this, a cleared session silently downgrades + # to 5m for its remainder). + try: + from src.state.session_start import initialize_prompt_cache_state + + initialize_prompt_cache_state() + except Exception: + pass # fail-soft: 5m below if latches.prompt_cache_1h_eligible is not True: return False return query_source in latches.prompt_cache_1h_allowlist diff --git a/src/state/session_start.py b/src/state/session_start.py index c4bbf7ff..cce7d32f 100644 --- a/src/state/session_start.py +++ b/src/state/session_start.py @@ -18,9 +18,10 @@ auth signals aren't available *before* the first API call — at which point the writer would need to be invoked earlier. -Call this from your application's session-start entry point (the -equivalent of TS's API-client init path). Today the recommended call -site is the REPL/TUI bootstrap, after settings have been loaded. +Wired (#285): ``initialize_prompt_cache_state`` runs from +``init.pre_action`` for every CLI invocation, and lazily from +``should_1h_cache_ttl`` for SDK paths that skip pre_action (or after a +/clear reset the latches). """ from __future__ import annotations @@ -30,6 +31,7 @@ from src.state.cache_state import ( evaluate_prompt_cache_1h_eligibility, get_beta_header_latches, + populate_prompt_cache_1h_allowlist, ) @@ -81,6 +83,59 @@ def initialize_prompt_cache_eligibility( ) +def _read_configured_1h_sources() -> list[str]: + """The configured 1h-cache query sources (#285). + + Resolution order: + + 1. ``CLAWCODEX_PROMPT_CACHE_1H_SOURCES`` — comma-separated query + sources (e.g. ``repl_main_thread``). The env var wins absolutely + when SET: ``CLAWCODEX_PROMPT_CACHE_1H_SOURCES=`` (set but empty) + is a kill switch that disables 1h even when settings configure + sources. + 2. ``settings.prompt_cache_1h_sources`` — a list in the settings + schema (consulted only when the env var is unset). + + Nothing configured means 1h caching stays dormant (the TS default + when the GrowthBook config returns nothing). + """ + raw_env = os.environ.get("CLAWCODEX_PROMPT_CACHE_1H_SOURCES") + if raw_env is not None: + return [part.strip() for part in raw_env.split(",") if part.strip()] + try: + from src.settings.settings import get_settings + + configured = get_settings().prompt_cache_1h_sources + if isinstance(configured, list): + return [s for s in configured if isinstance(s, str)] + except Exception: + pass # settings unavailable — dormant default + return [] + + +def initialize_prompt_cache_state() -> None: + """Session-start wiring for the 1h prompt-cache path (#285). + + Latches the eligibility decision (env-signal backed until an auth + subsystem lands) and installs the configured query-source allowlist. + Without this call, ``prompt_cache_1h_eligible`` stays ``None`` and + ``should_1h_cache_ttl`` always answers 5m — the pre-#285 dormant + state. Idempotent; fail-soft (cache TTL selection must never block + startup). + """ + try: + initialize_prompt_cache_eligibility() + sources = _read_configured_1h_sources() + if sources: + populate_prompt_cache_1h_allowlist(sources) + except Exception: + import logging + + logging.getLogger(__name__).debug( + "prompt-cache state initialization failed", exc_info=True + ) + + def reset_eligibility_for_tests() -> None: """Test-only: clear the latch so a fresh evaluation can happen.""" latches = get_beta_header_latches() @@ -89,5 +144,6 @@ def reset_eligibility_for_tests() -> None: __all__ = [ "initialize_prompt_cache_eligibility", + "initialize_prompt_cache_state", "reset_eligibility_for_tests", ] diff --git a/tests/test_session_start.py b/tests/test_session_start.py index 692a9c2b..9fb40652 100644 --- a/tests/test_session_start.py +++ b/tests/test_session_start.py @@ -10,6 +10,7 @@ from src.state.cache_state import ( get_beta_header_latches, + reset_for_test_only, should_1h_cache_ttl, ) from src.state.session_start import ( @@ -108,5 +109,170 @@ def test_initialize_settles_state_for_should_1h_cache_ttl(self) -> None: self.assertTrue(should_1h_cache_ttl("agent")) +# --------------------------------------------------------------------------- +# #285 — config-backed 1h allowlist + session-start wiring +# --------------------------------------------------------------------------- + + +class TestPopulateAllowlist(unittest.TestCase): + def setUp(self) -> None: + reset_for_test_only() + + def tearDown(self) -> None: + reset_for_test_only() + + def test_populates_once_and_is_sticky(self) -> None: + from src.state.cache_state import ( + get_prompt_cache_1h_allowlist, + populate_prompt_cache_1h_allowlist, + ) + + assert populate_prompt_cache_1h_allowlist(["repl_main_thread"]) is True + assert get_prompt_cache_1h_allowlist() == ["repl_main_thread"] + # Sticky: a second population mid-session is refused. + assert populate_prompt_cache_1h_allowlist(["other"]) is False + assert get_prompt_cache_1h_allowlist() == ["repl_main_thread"] + + def test_empty_and_garbage_entries_rejected(self) -> None: + from src.state.cache_state import ( + get_prompt_cache_1h_allowlist, + populate_prompt_cache_1h_allowlist, + ) + + assert populate_prompt_cache_1h_allowlist([]) is False + assert populate_prompt_cache_1h_allowlist([" ", ""]) is False + assert get_prompt_cache_1h_allowlist() == [] + assert populate_prompt_cache_1h_allowlist([" a ", "", "b"]) is True + assert get_prompt_cache_1h_allowlist() == ["a", "b"] + + +class TestInitializePromptCacheState(unittest.TestCase): + def setUp(self) -> None: + reset_for_test_only() + + def tearDown(self) -> None: + reset_for_test_only() + + def test_env_sources_and_eligibility_activate_1h(self) -> None: + from src.state.cache_state import should_1h_cache_ttl + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict( + os.environ, + { + "CLAUDE_CODE_IS_CLAUDE_AI_SUBSCRIBER": "1", + "CLAWCODEX_PROMPT_CACHE_1H_SOURCES": "repl_main_thread, sdk", + }, + ): + initialize_prompt_cache_state() + assert should_1h_cache_ttl("repl_main_thread") is True + assert should_1h_cache_ttl("sdk") is True + assert should_1h_cache_ttl("agent_explore") is False + + def test_sources_without_eligibility_stay_5m(self) -> None: + from src.state.cache_state import should_1h_cache_ttl + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict( + os.environ, + {"CLAWCODEX_PROMPT_CACHE_1H_SOURCES": "repl_main_thread"}, + clear=False, + ): + os.environ.pop("CLAUDE_CODE_IS_CLAUDE_AI_SUBSCRIBER", None) + os.environ.pop("CLAUDE_CODE_USER_TYPE", None) + initialize_prompt_cache_state() + assert should_1h_cache_ttl("repl_main_thread") is False + + def test_settings_sources_used_when_env_absent(self) -> None: + from types import SimpleNamespace + + from src.state.cache_state import get_prompt_cache_1h_allowlist + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAWCODEX_PROMPT_CACHE_1H_SOURCES", None) + with mock.patch( + "src.settings.settings.get_settings", + return_value=SimpleNamespace( + prompt_cache_1h_sources=["repl_main_thread"] + ), + ): + initialize_prompt_cache_state() + assert get_prompt_cache_1h_allowlist() == ["repl_main_thread"] + + def test_no_config_stays_dormant(self) -> None: + from src.state.cache_state import ( + get_prompt_cache_1h_allowlist, + should_1h_cache_ttl, + ) + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAWCODEX_PROMPT_CACHE_1H_SOURCES", None) + initialize_prompt_cache_state() + assert get_prompt_cache_1h_allowlist() == [] + assert should_1h_cache_ttl("repl_main_thread") is False + + def test_idempotent(self) -> None: + from src.state.cache_state import get_prompt_cache_1h_allowlist + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict( + os.environ, + { + "CLAUDE_CODE_IS_CLAUDE_AI_SUBSCRIBER": "1", + "CLAWCODEX_PROMPT_CACHE_1H_SOURCES": "repl_main_thread", + }, + ): + initialize_prompt_cache_state() + initialize_prompt_cache_state() + assert get_prompt_cache_1h_allowlist() == ["repl_main_thread"] + + def test_empty_env_var_is_a_kill_switch(self) -> None: + # CLAWCODEX_PROMPT_CACHE_1H_SOURCES set-but-empty disables 1h + # even when settings configure sources (env wins absolutely). + from types import SimpleNamespace + + from src.state.cache_state import get_prompt_cache_1h_allowlist + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict( + os.environ, {"CLAWCODEX_PROMPT_CACHE_1H_SOURCES": ""} + ): + with mock.patch( + "src.settings.settings.get_settings", + return_value=SimpleNamespace( + prompt_cache_1h_sources=["repl_main_thread"] + ), + ): + initialize_prompt_cache_state() + assert get_prompt_cache_1h_allowlist() == [] + + def test_1h_recovers_after_clear(self) -> None: + # /clear and /compact reset the latch singleton via + # clear_beta_header_latches; the lazy re-init in + # should_1h_cache_ttl must re-evaluate instead of silently + # downgrading the rest of the session to 5m. + from src.state.cache_state import ( + clear_beta_header_latches, + should_1h_cache_ttl, + ) + from src.state.session_start import initialize_prompt_cache_state + + with mock.patch.dict( + os.environ, + { + "CLAUDE_CODE_IS_CLAUDE_AI_SUBSCRIBER": "1", + "CLAWCODEX_PROMPT_CACHE_1H_SOURCES": "repl_main_thread", + }, + ): + initialize_prompt_cache_state() + assert should_1h_cache_ttl("repl_main_thread") is True + clear_beta_header_latches() + # Lazy re-init at the consumer recovers the 1h decision. + assert should_1h_cache_ttl("repl_main_thread") is True + + + if __name__ == "__main__": unittest.main()