diff --git a/api/composers.py b/api/composers.py index 1ada343..b2d76d4 100644 --- a/api/composers.py +++ b/api/composers.py @@ -98,12 +98,13 @@ def list_composers() -> tuple[Response, int] | Response: # load-bearing, not just a filter (Brad's review): the # sort key and the JSON's composerId both read off the # validated values, not the raw dict. - c["composerId"] = local.composer_id - c["lastUpdatedAt"] = local.last_updated_at - c["conversation"] = c.get("conversation") or [] - c["workspaceId"] = name - c["workspaceFolder"] = workspace_folder - composers.append((local, c)) + payload = local.cursor_storage_payload() + payload["composerId"] = local.composer_id + payload["lastUpdatedAt"] = local.last_updated_at + payload["conversation"] = payload.get("conversation") or [] + payload["workspaceId"] = name + payload["workspaceFolder"] = workspace_folder + composers.append((local, payload)) except SchemaError as e: _logger.warning( "Schema drift in %s: %s (%s)", @@ -201,7 +202,7 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response: # whether it's absent or None, so the response shape # is identical regardless of which branch resolved # the composer (CodeRabbit on PR #30). - payload = dict(local.raw) + payload = local.cursor_storage_payload() payload["conversation"] = payload.get("conversation") or [] return json_response(payload) except SchemaError as e: @@ -240,7 +241,7 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response: type(e).__name__, ) return json_response({"error": "Composer schema drift"}, 404) - payload = dict(composer.raw) + payload = composer.cursor_storage_payload() payload["conversation"] = payload.get("conversation") or [] return json_response(payload) except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError): diff --git a/benchmarks/baselines.json b/benchmarks/baselines.json index 1f3a5c0..25d09bd 100644 --- a/benchmarks/baselines.json +++ b/benchmarks/baselines.json @@ -1,32 +1,32 @@ { - "_note": "Gated means from ubuntu-latest CI benchmark-results.json. Values multiplied by 1.5x slack at generation time. Excluded from gate (recorded for reference): test_summary_cache_round_trip. Refresh after intentional speedups via reduce_baselines.py.", - "updated": "2026-06-25T23:36:11Z", + "_note": "Gated means from max of three ubuntu-latest CI runs, divided by 1.19, then scaled by 1.26/1.19 after a slower runner tier (~1.26x uniform). Sub-100us cache lookup benches excluded from gate.", + "updated": "2026-07-15T20:28:11Z", "machine": "Linux", "groups": { - "parse": { - "test_list_workspace_projects_nocache[composers-10]": 0.016421750017237738, - "test_list_workspace_projects_nocache[composers-50]": 0.07185380692856874, - "test_list_workspace_projects_nocache[composers-200]": 0.2388664538571439 - }, "export": { - "test_post_export_zip[composers-10]": 0.010621589857140498, - "test_post_export_zip[composers-50]": 0.03968703356250458 + "test_post_export_zip[composers-10]": 0.007295326498834289, + "test_post_export_zip[composers-50]": 0.02807004839347476 + }, + "parse": { + "test_list_workspace_projects_nocache[composers-10]": 0.010925576721547158, + "test_list_workspace_projects_nocache[composers-200]": 0.16456030084033657, + "test_list_workspace_projects_nocache[composers-50]": 0.04879363821057747 }, "search": { - "test_search_full_corpus_live_scan": 0.04461661563157736, - "test_search_full_corpus_indexed": 0.05512249660713918 + "test_search_full_corpus_indexed": 0.037677749823188784, + "test_search_full_corpus_live_scan": 0.02928151049402375 }, "summary-cache": { - "test_summary_cache_lookup[hit]": 7.249851343825762e-05, - "test_summary_cache_lookup[miss]": 7.193702095574013e-05, - "test_composer_map_cache_lookup[hit]": 7.151645086519804e-05, - "test_composer_map_cache_lookup[miss]": 7.112598943352091e-05, - "test_fingerprint_workspace_entries[10]": 0.0024127972424549185, - "test_fingerprint_workspace_entries[50]": 0.010196820941858245, - "test_fingerprint_workspace_entries[200]": 0.029070524094341035, - "test_summary_cache_round_trip": 0.0004703680658560554, - "test_tab_summary_cache_lookup[hit]": 7.844850562859133e-05, - "test_tab_summary_cache_lookup[miss]": 7.843399021512e-05 + "test_composer_map_cache_lookup[hit]": 5.466220078335517e-05, + "test_composer_map_cache_lookup[miss]": 5.375314143320713e-05, + "test_fingerprint_workspace_entries[10]": 0.0016242627916219074, + "test_fingerprint_workspace_entries[200]": 0.020266418220695106, + "test_fingerprint_workspace_entries[50]": 0.007026522919453581, + "test_summary_cache_lookup[hit]": 5.5204183309659385e-05, + "test_summary_cache_lookup[miss]": 5.486746322524216e-05, + "test_summary_cache_round_trip": 0.00030774767229821784, + "test_tab_summary_cache_lookup[hit]": 6.313878206062426e-05, + "test_tab_summary_cache_lookup[miss]": 6.213423742848226e-05 } } } diff --git a/models/conversation.py b/models/conversation.py index 495940f..3c45d99 100644 --- a/models/conversation.py +++ b/models/conversation.py @@ -4,6 +4,17 @@ from dataclasses import dataclass, field from typing import Any, cast +from models.conversation_types import ( + BubbleContextDict, + BubbleMetadataDict, + ContextWindowStatusDict, + FileUriDict, + ModelInfoDict, + ThinkingDict, + TokenCountDict, + ToolFormerDataDict, + ToolResultEntry, +) from models.errors import SchemaError from models.from_dict_validation import ( require_dict, @@ -16,6 +27,50 @@ _logger = logging.getLogger(__name__) +def _filter_str_list_elements( + value: list[Any], + *, + model: str, + record_id: str, + field: str, +) -> list[str]: + filtered: list[str] = [] + for item in value: + if isinstance(item, str): + filtered.append(item) + else: + _logger.warning( + "Schema drift in %s %s: invalid %s element (expected str, got %s)", + model, + record_id, + field, + type(item).__name__, + ) + return filtered + + +def _filter_dict_list_elements( + value: list[Any], + *, + model: str, + record_id: str, + field: str, +) -> list[FileUriDict]: + filtered: list[FileUriDict] = [] + for item in value: + if isinstance(item, dict): + filtered.append(cast(FileUriDict, item)) + else: + _logger.warning( + "Schema drift in %s %s: invalid %s element (expected dict, got %s)", + model, + record_id, + field, + type(item).__name__, + ) + return filtered + + @dataclass(frozen=True) class Composer: """Cursor conversation row from globalStorage cursorDiskKV; requires fullConversationHeadersOnly + createdAt.""" @@ -26,7 +81,7 @@ class Composer: name: str | None = None last_updated_at: Any = None model_config: dict[str, Any] = field(default_factory=dict) - raw: dict[str, Any] = field(default_factory=dict) + _raw: dict[str, Any] = field(default_factory=dict, repr=False) @classmethod def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer": @@ -79,12 +134,19 @@ def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer": name=raw.get("name"), last_updated_at=raw.get("lastUpdatedAt"), model_config=model_config, - raw=raw, + _raw=raw, ) + def cursor_storage_payload(self) -> dict[str, Any]: + """Shallow copy of stored Cursor JSON for API passthrough. + + Prefer typed accessors for field reads. + """ + return dict(self._raw) + @property def newly_created_files(self) -> list[Any]: - value = self.raw.get("newlyCreatedFiles") + value = self._raw.get("newlyCreatedFiles") if value is None: return [] if not isinstance(value, list): @@ -98,7 +160,7 @@ def newly_created_files(self) -> list[Any]: @property def code_block_data(self) -> dict[str, Any] | None: - value = self.raw.get("codeBlockData") + value = self._raw.get("codeBlockData") if value is None: return None if not isinstance(value, dict): @@ -113,7 +175,7 @@ def code_block_data(self) -> dict[str, Any] | None: @property def usage_data(self) -> dict[str, Any]: """Composer cost rollup; empty dict when absent (common).""" - value = self.raw.get("usageData") + value = self._raw.get("usageData") if value is None: return {} if not isinstance(value, dict): @@ -127,9 +189,9 @@ def usage_data(self) -> dict[str, Any]: return value def _optional_counter(self, key: str) -> int | float: - value = self.raw.get(key, 0) + value = self._raw.get(key, 0) if isinstance(value, bool) or not isinstance(value, (int, float)): - if key in self.raw: + if key in self._raw: suffix = f" {self.composer_id}" if self.composer_id else "" _logger.warning( "Schema drift in Composer%s: invalid type for %s (expected number, got %s)", @@ -172,7 +234,7 @@ class WorkspaceLocalComposer: composer_id: str last_updated_at: Any = None - raw: dict[str, Any] = field(default_factory=dict) + _raw: dict[str, Any] = field(default_factory=dict, repr=False) @classmethod def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer": @@ -194,9 +256,13 @@ def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer": return cls( composer_id=composer_id, last_updated_at=raw.get("lastUpdatedAt"), - raw=raw, + _raw=raw, ) + def cursor_storage_payload(self) -> dict[str, Any]: + """Shallow copy of stored Cursor JSON for API passthrough.""" + return dict(self._raw) + @dataclass(frozen=True) class Bubble: @@ -206,7 +272,7 @@ class Bubble: """ bubble_id: str - raw: dict[str, Any] = field(default_factory=dict) + _raw: dict[str, Any] = field(default_factory=dict, repr=False) @classmethod def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble": @@ -224,17 +290,24 @@ def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble": """ raw = require_dict(raw, model="Bubble", field="bubble") require_non_empty_str(bubble_id, model="Bubble", field="bubbleId") - return cls(bubble_id=bubble_id, raw=raw) + return cls(bubble_id=bubble_id, _raw=raw) + + def cursor_storage_payload(self) -> dict[str, Any]: + """Shallow copy of stored Cursor JSON for API passthrough. + + Prefer typed accessors for field reads. + """ + return dict(self._raw) @property def text(self) -> str | None: """Plain ``text`` field; richText is handled by :func:`extract_text_from_bubble`.""" - value = self.raw.get("text") + value = self._raw.get("text") return value if isinstance(value, str) else None @property - def metadata(self) -> dict[str, Any]: - value = self.raw.get("metadata") + def metadata(self) -> BubbleMetadataDict: + value = self._raw.get("metadata") if value is None: return {} if not isinstance(value, dict): @@ -244,11 +317,11 @@ def metadata(self) -> dict[str, Any]: type(value).__name__, ) return {} - return value + return cast(BubbleMetadataDict, value) @property - def relevant_files(self) -> list[Any]: - value = self.raw.get("relevantFiles") + def relevant_files(self) -> list[str]: + value = self._raw.get("relevantFiles") if value is None: return [] if not isinstance(value, list): @@ -258,11 +331,16 @@ def relevant_files(self) -> list[Any]: type(value).__name__, ) return [] - return value + return _filter_str_list_elements( + value, + model="Bubble", + record_id=self.bubble_id, + field="relevantFiles", + ) @property - def attached_file_code_chunks_uris(self) -> list[Any]: - value = self.raw.get("attachedFileCodeChunksUris") + def attached_file_code_chunks_uris(self) -> list[FileUriDict]: + value = self._raw.get("attachedFileCodeChunksUris") if value is None: return [] if not isinstance(value, list): @@ -272,11 +350,16 @@ def attached_file_code_chunks_uris(self) -> list[Any]: type(value).__name__, ) return [] - return value + return _filter_dict_list_elements( + value, + model="Bubble", + record_id=self.bubble_id, + field="attachedFileCodeChunksUris", + ) @property - def context(self) -> dict[str, Any]: - value = self.raw.get("context") + def context(self) -> BubbleContextDict: + value = self._raw.get("context") if value is None: return {} if not isinstance(value, dict): @@ -286,11 +369,11 @@ def context(self) -> dict[str, Any]: type(value).__name__, ) return {} - return value + return cast(BubbleContextDict, value) @property - def token_count(self) -> dict[str, Any] | None: - value = self.raw.get("tokenCount") + def token_count(self) -> TokenCountDict | None: + value = self._raw.get("tokenCount") if value is None: return None if not isinstance(value, dict): @@ -300,11 +383,11 @@ def token_count(self) -> dict[str, Any] | None: type(value).__name__, ) return None - return value + return cast(TokenCountDict, value) @property - def tool_former_data(self) -> dict[str, Any] | None: - value = self.raw.get("toolFormerData") + def tool_former_data(self) -> ToolFormerDataDict | None: + value = self._raw.get("toolFormerData") if value is None: return None if not isinstance(value, dict): @@ -314,11 +397,11 @@ def tool_former_data(self) -> dict[str, Any] | None: type(value).__name__, ) return None - return value + return cast(ToolFormerDataDict, value) @property - def model_info(self) -> dict[str, Any]: - value = self.raw.get("modelInfo") + def model_info(self) -> ModelInfoDict: + value = self._raw.get("modelInfo") if value is None: return {} if not isinstance(value, dict): @@ -328,15 +411,15 @@ def model_info(self) -> dict[str, Any]: type(value).__name__, ) return {} - return value + return cast(ModelInfoDict, value) @property - def thinking(self) -> str | dict[str, Any] | None: - value = self.raw.get("thinking") + def thinking(self) -> str | ThinkingDict | None: + value = self._raw.get("thinking") if value is None: return None if isinstance(value, (str, dict)): - return value + return cast(str | ThinkingDict, value) _logger.warning( "Schema drift in Bubble %s: invalid type for thinking (expected str or dict, got %s)", self.bubble_id, @@ -346,7 +429,7 @@ def thinking(self) -> str | dict[str, Any] | None: @property def thinking_duration_ms(self) -> int | float | None: - value = self.raw.get("thinkingDurationMs") + value = self._raw.get("thinkingDurationMs") if value is None: return None if isinstance(value, bool) or not isinstance(value, (int, float)): @@ -359,8 +442,8 @@ def thinking_duration_ms(self) -> int | float | None: return cast(int | float, value) @property - def context_window_status_at_creation(self) -> dict[str, Any]: - value = self.raw.get("contextWindowStatusAtCreation") + def context_window_status_at_creation(self) -> ContextWindowStatusDict: + value = self._raw.get("contextWindowStatusAtCreation") if value is None: return {} if not isinstance(value, dict): @@ -370,11 +453,11 @@ def context_window_status_at_creation(self) -> dict[str, Any]: type(value).__name__, ) return {} - return value + return cast(ContextWindowStatusDict, value) @property - def tool_results(self) -> list[Any] | None: - value = self.raw.get("toolResults") + def tool_results(self) -> list[ToolResultEntry] | None: + value = self._raw.get("toolResults") if value is None: return None if not isinstance(value, list): @@ -384,12 +467,12 @@ def tool_results(self) -> list[Any] | None: type(value).__name__, ) return None - return value + return cast(list[ToolResultEntry], value) def bubble_timestamp_ms(self) -> int | float | None: """``createdAt`` or ``timestamp`` in milliseconds when present.""" for key in ("createdAt", "timestamp"): - value = self.raw.get(key) + value = self._raw.get(key) if isinstance(value, (int, float)) and not isinstance(value, bool): return value return None diff --git a/models/conversation_types.py b/models/conversation_types.py new file mode 100644 index 0000000..b5ad3ef --- /dev/null +++ b/models/conversation_types.py @@ -0,0 +1,66 @@ +"""Typed shapes for Cursor JSON fields on :class:`models.conversation.Bubble`.""" + +from __future__ import annotations + +from typing import Any, TypedDict + + +class FileUriDict(TypedDict, total=False): + """URI object on attached-file or context entries.""" + + path: str + + +class BubbleMetadataDict(TypedDict, total=False): + """Storage ``metadata`` blob on a bubble row (subset we read here). + + Rich display metadata (tool calls, token counts, thinking) is assembled in + :func:`utils.display_bubble.build_storage_bubble_metadata` from typed + bubble fields, not from this dict alone. + """ + + modelName: str + + +class TokenCountDict(TypedDict, total=False): + inputTokens: int + outputTokens: int + cachedTokens: int + + +class ModelInfoDict(TypedDict, total=False): + modelName: str + + +class ContextWindowStatusDict(TypedDict, total=False): + percentageRemainingFloat: float + percentageRemaining: float + tokensUsed: int + tokenLimit: int + + +class FileSelectionDict(TypedDict, total=False): + uri: FileUriDict + + +class BubbleContextDict(TypedDict, total=False): + fileSelections: list[FileSelectionDict] + + +class ToolFormerDataDict(TypedDict, total=False): + name: str + status: str + params: str | dict[str, Any] + rawArgs: str + result: str + + +class ThinkingDict(TypedDict, total=False): + text: str + + +class ToolResultEntry(TypedDict, total=False): + """One element of a bubble ``toolResults`` list.""" + + toolName: str + result: str | dict[str, Any] diff --git a/models/raw_access.py b/models/raw_access.py index 929afc4..0700385 100644 --- a/models/raw_access.py +++ b/models/raw_access.py @@ -3,7 +3,13 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any, cast + +from models.conversation_types import BubbleContextDict + +if TYPE_CHECKING: + from models.conversation import Bubble, Composer + from models.conversation_types import FileUriDict _logger = logging.getLogger(__name__) @@ -219,15 +225,13 @@ def message_request_context_project_layouts( def composer_headers( - data: Any, + data: Composer | dict[str, Any], composer_id: str, ) -> list[dict[str, Any]]: from models.conversation import Composer if isinstance(data, Composer): return data.full_conversation_headers_only - if not isinstance(data, dict): - return [] return _optional_list_absent_ok( data, "fullConversationHeadersOnly", @@ -236,13 +240,13 @@ def composer_headers( ) -def composer_newly_created_files(data: Any, composer_id: str) -> list[Any]: +def composer_newly_created_files( + data: Composer | dict[str, Any], composer_id: str +) -> list[Any]: from models.conversation import Composer if isinstance(data, Composer): return data.newly_created_files - if not isinstance(data, dict): - return [] return _optional_list_absent_ok( data, "newlyCreatedFiles", @@ -251,13 +255,13 @@ def composer_newly_created_files(data: Any, composer_id: str) -> list[Any]: ) -def composer_code_block_data(data: Any, composer_id: str) -> dict[str, Any] | None: +def composer_code_block_data( + data: Composer | dict[str, Any], composer_id: str +) -> dict[str, Any] | None: from models.conversation import Composer if isinstance(data, Composer): return data.code_block_data - if not isinstance(data, dict): - return None return _optional_dict_absent_ok( data, "codeBlockData", @@ -266,47 +270,55 @@ def composer_code_block_data(data: Any, composer_id: str) -> dict[str, Any] | No ) -def bubble_relevant_files(bubble: Any, bubble_id: str = "") -> list[Any]: - from models.conversation import Bubble +def bubble_relevant_files( + bubble: Bubble | dict[str, Any], bubble_id: str = "" +) -> list[str]: + from models.conversation import Bubble, _filter_str_list_elements if isinstance(bubble, Bubble): return bubble.relevant_files - if isinstance(bubble, dict): - return _optional_list_absent_ok( + return _filter_str_list_elements( + _optional_list_absent_ok( bubble, "relevantFiles", model="Bubble", entity_id=bubble_id, - ) - return [] + ), + model="Bubble", + record_id=bubble_id, + field="relevantFiles", + ) -def bubble_attached_file_uris(bubble: Any, bubble_id: str = "") -> list[Any]: - from models.conversation import Bubble +def bubble_attached_file_uris( + bubble: Bubble | dict[str, Any], bubble_id: str = "" +) -> list[FileUriDict]: + from models.conversation import Bubble, _filter_dict_list_elements if isinstance(bubble, Bubble): return bubble.attached_file_code_chunks_uris - if isinstance(bubble, dict): - return _optional_list_absent_ok( + return _filter_dict_list_elements( + _optional_list_absent_ok( bubble, "attachedFileCodeChunksUris", model="Bubble", entity_id=bubble_id, - ) - return [] + ), + model="Bubble", + record_id=bubble_id, + field="attachedFileCodeChunksUris", + ) -def bubble_context(bubble: Any, bubble_id: str = "") -> dict[str, Any]: +def bubble_context(bubble: Bubble | dict[str, Any], bubble_id: str = "") -> BubbleContextDict: from models.conversation import Bubble if isinstance(bubble, Bubble): return bubble.context - if isinstance(bubble, dict): - ctx = _optional_dict_absent_ok( - bubble, - "context", - model="Bubble", - entity_id=bubble_id, - ) - return ctx if ctx is not None else {} - return {} + ctx = _optional_dict_absent_ok( + bubble, + "context", + model="Bubble", + entity_id=bubble_id, + ) + return cast(BubbleContextDict, ctx if ctx is not None else {}) diff --git a/requirements-lock.txt b/requirements-lock.txt index beaa107..e6c99be 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -14,5 +14,5 @@ fpdf2==2.8.7 # via -r requirements.txt itsdangerous==2.2.0 # via flask jinja2==3.1.6 # via flask markupsafe==3.0.3 # via flask, jinja2, werkzeug -pillow==12.2.0 # via -r requirements.txt, fpdf2 +pillow==12.3.0 # via -r requirements.txt, fpdf2 werkzeug==3.1.8 # via flask diff --git a/scripts/check_benchmark_regression.py b/scripts/check_benchmark_regression.py index 6655460..b29ae70 100644 --- a/scripts/check_benchmark_regression.py +++ b/scripts/check_benchmark_regression.py @@ -17,9 +17,17 @@ EXCLUDED_FROM_GATE: frozenset[str] = frozenset( { # round_trip calls set_cached_projects (file write) + get_cached_projects (file read) - # each round. OS page-cache state on shared runners causes 3–5x variation between + # each round. OS page-cache state on shared runners causes 3-5x variation between # consecutive CI runs, making this ungatable with any reasonable slack. "test_summary_cache_round_trip", + # Sub-100µs in-memory cache lookups vary 2.5x+ between consecutive ubuntu-latest + # runs; gated ratio band (0.5x to 1.2x) cannot bracket both without false failures. + "test_summary_cache_lookup[hit]", + "test_summary_cache_lookup[miss]", + "test_composer_map_cache_lookup[hit]", + "test_composer_map_cache_lookup[miss]", + "test_tab_summary_cache_lookup[hit]", + "test_tab_summary_cache_lookup[miss]", } ) diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index 9fddd30..803919b 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -137,7 +137,7 @@ def _assemble_tab_from_composer_data( Args: composer_id: Composer UUID. - composer: Validated composer model (typed field access on ``.raw``). + composer: Validated composer model (typed field access, not raw dict reads). bubble_map: ``{bubble_id: Bubble}`` — global or scoped. contexts: ``messageRequestContext`` entries for *this* composer (list of dicts, each with an injected ``contextId`` key and a diff --git a/tests/test_blob_parsing_fuzz.py b/tests/test_blob_parsing_fuzz.py index e7b5f1d..fc2cec8 100644 --- a/tests/test_blob_parsing_fuzz.py +++ b/tests/test_blob_parsing_fuzz.py @@ -184,7 +184,8 @@ def _assemble_workspace_bubble(bubble_id: object, value: object) -> dict | None: except (json.JSONDecodeError, TypeError, ValueError): return None try: - return Bubble.from_dict(parsed, bubble_id=bubble_id).raw # type: ignore[arg-type] + bubble = Bubble.from_dict(parsed, bubble_id=bubble_id) + return bubble.cursor_storage_payload() # type: ignore[arg-type] except SchemaError: return None @@ -205,7 +206,8 @@ def test_never_raises_unhandled(self, raw: dict, bubble_id: str) -> None: if bubble is None: return self.assertEqual(bubble.bubble_id, bubble_id) - self.assertIs(bubble.raw, raw) + # from_dict stores the parsed dict by reference (no copy on construction). + self.assertIs(bubble._raw, raw) @given(raw=_BUBBLE_RAW_ANY, bubble_id=_BUBBLE_ID_ANY) @settings(max_examples=80, deadline=None) diff --git a/tests/test_check_benchmark_regression.py b/tests/test_check_benchmark_regression.py index 873d68a..5bbe3a0 100644 --- a/tests/test_check_benchmark_regression.py +++ b/tests/test_check_benchmark_regression.py @@ -7,6 +7,7 @@ import pytest from scripts.check_benchmark_regression import ( + EXCLUDED_FROM_GATE, BenchmarkDataError, check_regression, load_baseline_means, @@ -14,7 +15,18 @@ normalize_benchmark_name, ) -GATED_BENCH = "test_summary_cache_lookup[hit]" +GATED_BENCH = "test_fingerprint_workspace_entries[10]" + +# Pytest benchmark node IDs (parametrize ids) that must stay in EXCLUDED_FROM_GATE. +EXPECTED_EXCLUDED_FROM_GATE = ( + "test_summary_cache_round_trip", + "test_summary_cache_lookup[hit]", + "test_summary_cache_lookup[miss]", + "test_composer_map_cache_lookup[hit]", + "test_composer_map_cache_lookup[miss]", + "test_tab_summary_cache_lookup[hit]", + "test_tab_summary_cache_lookup[miss]", +) def _write_results(path, benchmarks: list[dict]) -> None: @@ -31,6 +43,43 @@ def _write_baselines(path, groups: dict[str, dict[str, float]]) -> None: ) +def test_excluded_from_gate_matches_benchmark_ids() -> None: + assert EXCLUDED_FROM_GATE == frozenset(EXPECTED_EXCLUDED_FROM_GATE) + + +@pytest.mark.parametrize("excluded_bench", EXPECTED_EXCLUDED_FROM_GATE) +def test_excluded_benchmark_skips_regression_gate( + tmp_path, excluded_bench: str +) -> None: + results = tmp_path / "results.json" + baselines = tmp_path / "baselines.json" + _write_results( + results, + [{"name": excluded_bench, "stats": {"mean": 1.0}}], + ) + _write_baselines( + baselines, + {"summary-cache": {excluded_bench: 0.1}}, + ) + + assert check_regression(results, baselines) == 0 + + +@pytest.mark.parametrize("excluded_bench", EXPECTED_EXCLUDED_FROM_GATE) +def test_excluded_benchmark_missing_result_does_not_fail( + tmp_path, excluded_bench: str +) -> None: + results = tmp_path / "results.json" + baselines = tmp_path / "baselines.json" + _write_results(results, []) + _write_baselines( + baselines, + {"summary-cache": {excluded_bench: 0.1}}, + ) + + assert check_regression(results, baselines) == 0 + + def test_normalize_benchmark_name_strips_module_prefix() -> None: full = "tests/benchmarks/test_summary_cache_bench.py::test_summary_cache_lookup[hit]" assert normalize_benchmark_name(full) == "test_summary_cache_lookup[hit]" @@ -68,12 +117,12 @@ def test_missing_baseline_warns_without_failing( results, [ {"name": "test_new_bench", "stats": {"mean": 0.01}}, - {"name": GATED_BENCH, "stats": {"mean": 0.0001}}, + {"name": GATED_BENCH, "stats": {"mean": 0.001}}, ], ) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0001}}, + {"summary-cache": {GATED_BENCH: 0.001}}, ) assert check_regression(results, baselines) == 0 @@ -86,11 +135,11 @@ def test_regression_over_threshold_fails(tmp_path, capsys: pytest.CaptureFixture baselines = tmp_path / "baselines.json" _write_results( results, - [{"name": GATED_BENCH, "stats": {"mean": 0.00025}}], + [{"name": GATED_BENCH, "stats": {"mean": 0.0025}}], ) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0002}}, + {"summary-cache": {GATED_BENCH: 0.002}}, ) assert check_regression(results, baselines) == 1 @@ -103,11 +152,11 @@ def test_within_threshold_passes(tmp_path) -> None: baselines = tmp_path / "baselines.json" _write_results( results, - [{"name": GATED_BENCH, "stats": {"mean": 0.00022}}], + [{"name": GATED_BENCH, "stats": {"mean": 0.0022}}], ) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0002}}, + {"summary-cache": {GATED_BENCH: 0.002}}, ) assert check_regression(results, baselines) == 0 @@ -137,7 +186,7 @@ def test_zero_baseline_skips_ratio_check(tmp_path, capsys: pytest.CaptureFixture baselines = tmp_path / "baselines.json" _write_results( results, - [{"name": GATED_BENCH, "stats": {"mean": 0.00025}}], + [{"name": GATED_BENCH, "stats": {"mean": 0.0025}}], ) _write_baselines( baselines, @@ -153,11 +202,11 @@ def test_exactly_at_threshold_passes(tmp_path) -> None: baselines = tmp_path / "baselines.json" _write_results( results, - [{"name": GATED_BENCH, "stats": {"mean": 0.00024}}], + [{"name": GATED_BENCH, "stats": {"mean": 0.0024}}], ) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0002}}, + {"summary-cache": {GATED_BENCH: 0.002}}, ) assert check_regression(results, baselines) == 0 @@ -169,7 +218,7 @@ def test_missing_current_result_fails(tmp_path, capsys: pytest.CaptureFixture[st _write_results(results, []) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0002}}, + {"summary-cache": {GATED_BENCH: 0.002}}, ) assert check_regression(results, baselines) == 1 @@ -184,7 +233,7 @@ def test_main_reports_benchmark_data_error(tmp_path, capsys: pytest.CaptureFixtu bad = tmp_path / "bad.json" bad.write_text("{}", encoding="utf-8") baselines = tmp_path / "baselines.json" - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) assert main([str(bad), str(baselines)]) == 2 assert "ERROR:" in capsys.readouterr().err @@ -195,8 +244,8 @@ def test_duplicate_baseline_name_raises(tmp_path) -> None: _write_baselines( baselines, { - "summary-cache": {GATED_BENCH: 0.0002}, - "export": {GATED_BENCH: 0.0003}, + "summary-cache": {GATED_BENCH: 0.002}, + "export": {GATED_BENCH: 0.003}, }, ) @@ -220,11 +269,11 @@ def test_stale_baseline_fails(tmp_path, capsys: pytest.CaptureFixture[str]) -> N baselines = tmp_path / "baselines.json" _write_results( results, - [{"name": GATED_BENCH, "stats": {"mean": 0.00005}}], + [{"name": GATED_BENCH, "stats": {"mean": 0.0005}}], ) _write_baselines( baselines, - {"summary-cache": {GATED_BENCH: 0.0002}}, + {"summary-cache": {GATED_BENCH: 0.002}}, ) assert check_regression(results, baselines) == 1 @@ -237,8 +286,8 @@ def test_main_rejects_invalid_threshold(tmp_path, capsys: pytest.CaptureFixture[ results = tmp_path / "results.json" baselines = tmp_path / "baselines.json" - _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.0001}}]) - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.001}}]) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) assert main([str(results), str(baselines), "--threshold", "1.0"]) == 2 assert "threshold must be greater than 1" in capsys.readouterr().err @@ -249,8 +298,8 @@ def test_main_rejects_invalid_stale_floor(tmp_path, capsys: pytest.CaptureFixtur results = tmp_path / "results.json" baselines = tmp_path / "baselines.json" - _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.0001}}]) - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.001}}]) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) assert main([str(results), str(baselines), "--stale-floor", "1.5"]) == 2 assert "stale_floor must be between 0 and 1" in capsys.readouterr().err @@ -259,8 +308,8 @@ def test_main_rejects_invalid_stale_floor(tmp_path, capsys: pytest.CaptureFixtur def test_check_regression_rejects_invalid_threshold(tmp_path) -> None: results = tmp_path / "results.json" baselines = tmp_path / "baselines.json" - _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.0001}}]) - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.001}}]) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) with pytest.raises(BenchmarkDataError, match="threshold must be greater than 1"): check_regression(results, baselines, threshold=1.0) @@ -269,8 +318,8 @@ def test_check_regression_rejects_invalid_threshold(tmp_path) -> None: def test_check_regression_rejects_non_finite_threshold(tmp_path) -> None: results = tmp_path / "results.json" baselines = tmp_path / "baselines.json" - _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.0001}}]) - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.001}}]) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) with pytest.raises(BenchmarkDataError, match="threshold must be finite"): check_regression(results, baselines, threshold=float("nan")) @@ -281,8 +330,8 @@ def test_main_rejects_non_finite_threshold(tmp_path, capsys: pytest.CaptureFixtu results = tmp_path / "results.json" baselines = tmp_path / "baselines.json" - _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.0001}}]) - _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}}) + _write_results(results, [{"name": GATED_BENCH, "stats": {"mean": 0.001}}]) + _write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.002}}) assert main([str(results), str(baselines), "--threshold", "inf"]) == 2 assert "threshold must be finite" in capsys.readouterr().err diff --git a/tests/test_models.py b/tests/test_models.py index c7e46eb..8e456ec 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -55,7 +55,7 @@ def test_parses_required_and_optional_fields(self) -> None: self.assertEqual(composer.last_updated_at, 1_715_000_500_000) self.assertEqual(len(composer.full_conversation_headers_only), 2) self.assertEqual(composer.model_config.get("modelName"), "claude-opus-4-7") - self.assertIs(composer.raw, GOOD_COMPOSER_RAW) + self.assertIs(composer._raw, GOOD_COMPOSER_RAW) def test_workspace_parses_with_optional_folder(self) -> None: ws = Workspace.from_dict({"folder": "/home/zilin/projects/x"}, workspace_id="ws-1") @@ -93,6 +93,38 @@ def test_export_entry_parses(self) -> None: self.assertEqual(entry.workspace, "ws-1") +class CursorStoragePayloadContract(unittest.TestCase): + """cursor_storage_payload() must shallow-copy _raw for API passthrough.""" + + def _assert_shallow_copy(self, model: Composer | Bubble | WorkspaceLocalComposer, raw: dict) -> None: + raw["nested"] = {"k": 1} + payload = model.cursor_storage_payload() + self.assertIsNot(payload, raw) + self.assertEqual(payload, raw) + self.assertIs(payload["nested"], raw["nested"]) + payload["__api_mutation__"] = True + self.assertNotIn("__api_mutation__", raw) + + def test_composer_cursor_storage_payload_is_shallow_copy(self) -> None: + raw = dict(GOOD_COMPOSER_RAW) + composer = Composer.from_dict(raw, composer_id="cid-001") + self._assert_shallow_copy(composer, raw) + + def test_bubble_cursor_storage_payload_is_shallow_copy(self) -> None: + raw = {"text": "hi", "metadata": {"model": "gpt-4"}} + bubble = Bubble.from_dict(raw, bubble_id="b-1") + self._assert_shallow_copy(bubble, raw) + + def test_workspace_local_composer_cursor_storage_payload_is_shallow_copy(self) -> None: + raw = { + "composerId": "cid-local-1", + "lastUpdatedAt": 1_715_000_500_000, + "conversation": [{"bubbleId": "b-1"}], + } + local = WorkspaceLocalComposer.from_dict(raw) + self._assert_shallow_copy(local, raw) + + class ComposerMissingFieldSchema(unittest.TestCase): def test_missing_full_conversation_headers_only_raises(self) -> None: bad = {k: v for k, v in GOOD_COMPOSER_RAW.items() if k != "fullConversationHeadersOnly"} diff --git a/tests/test_models_wired_at_read_sites.py b/tests/test_models_wired_at_read_sites.py index 991dec9..5ef6af4 100644 --- a/tests/test_models_wired_at_read_sites.py +++ b/tests/test_models_wired_at_read_sites.py @@ -274,7 +274,7 @@ def swapped_from_dict(raw): # type: ignore[no-redef] return WorkspaceLocalComposer( composer_id=cid, last_updated_at=1000 if cid == "cmp-A" else 9000, - raw=raw, + _raw=raw, ) app = create_app() @@ -292,6 +292,38 @@ def swapped_from_dict(raw): # type: ignore[no-redef] "is back to being just a filter.", ) + def test_list_composers_response_fields_do_not_mutate_storage_raw(self): + from app import create_app + from models import WorkspaceLocalComposer + import api.composers as composers_mod + + captured: list[WorkspaceLocalComposer] = [] + original_from_dict = WorkspaceLocalComposer.from_dict + + def capturing_from_dict(raw): # type: ignore[no-untyped-def] + local = original_from_dict(raw) + captured.append(local) + return local + + app = create_app() + app.config["TESTING"] = True + app.config["EXCLUSION_RULES"] = [] + with patch.object( + composers_mod.WorkspaceLocalComposer, + "from_dict", + side_effect=capturing_from_dict, + ): + client = app.test_client() + response = client.get("/api/composers") + self.assertEqual(response.status_code, 200) + rows = response.get_json() + row = next(c for c in rows if c["composerId"] == COMPOSER_ID) + self.assertEqual(row["workspaceId"], WORKSPACE_ID) + + local = next(c for c in captured if c.composer_id == COMPOSER_ID) + self.assertNotIn("workspaceId", local._raw) + self.assertNotIn("workspaceFolder", local._raw) + class TestGetComposerValidatesSchema(unittest.TestCase): # Brad's follow-up finding: list_composers() validates each row via diff --git a/tests/test_raw_accessors.py b/tests/test_raw_accessors.py index 1be2c57..620f441 100644 --- a/tests/test_raw_accessors.py +++ b/tests/test_raw_accessors.py @@ -15,6 +15,9 @@ from models.conversation import Bubble, Composer from utils.display_bubble import bubble_display_timestamp_ms from models.raw_access import ( + bubble_attached_file_uris, + bubble_context, + bubble_relevant_files, composer_newly_created_files, conversation_header_bubble_id, message_request_context_project_layouts, @@ -46,6 +49,34 @@ def test_bubble_relevant_files_empty_when_key_missing(self) -> None: with self.assertNoLogs("models.conversation", level="WARNING"): self.assertEqual(bubble.relevant_files, []) + def test_bubble_relevant_files_skips_non_str_elements(self) -> None: + bubble = Bubble.from_dict( + {"relevantFiles": ["/good.py", 123, None, "/also.py"]}, + bubble_id="b-rel", + ) + with self.assertLogs("models.conversation", level="WARNING") as logs: + self.assertEqual(bubble.relevant_files, ["/good.py", "/also.py"]) + self.assertTrue(any("relevantFiles" in m for m in logs.output), logs.output) + + def test_bubble_attached_file_uris_skips_non_dict_elements(self) -> None: + bubble = Bubble.from_dict( + { + "attachedFileCodeChunksUris": [ + {"path": "/a.py"}, + "bad", + {"path": "/b.py"}, + ] + }, + bubble_id="b-uri", + ) + with self.assertLogs("models.conversation", level="WARNING") as logs: + uris = bubble.attached_file_code_chunks_uris + self.assertEqual(uris, [{"path": "/a.py"}, {"path": "/b.py"}]) + self.assertTrue( + any("attachedFileCodeChunksUris" in m for m in logs.output), + logs.output, + ) + def test_project_layouts_silent_when_key_missing(self) -> None: with self.assertNoLogs("models.raw_access", level="WARNING"): layouts = message_request_context_project_layouts({}, composer_id="cmp-1") @@ -135,6 +166,53 @@ def test_dict_bridge_newly_created_files_matches_composer_property(self) -> None composer_newly_created_files(composer, "cid-bridge"), ) + def test_dict_bridge_relevant_files_skips_non_str_elements(self) -> None: + raw = {"relevantFiles": ["/good.py", 123, "/also.py"]} + bubble = Bubble.from_dict(raw, bubble_id="b-bridge") + with self.assertLogs("models.conversation", level="WARNING") as logs: + self.assertEqual( + bubble_relevant_files(raw, "b-bridge"), + ["/good.py", "/also.py"], + ) + self.assertEqual(bubble.relevant_files, bubble_relevant_files(bubble, "b-bridge")) + self.assertTrue(any("relevantFiles" in m for m in logs.output), logs.output) + + def test_dict_bridge_bubble_context_matches_bubble_property(self) -> None: + raw = {"context": {"fileSelections": [{"uri": {"path": "/a.py"}}]}} + bubble = Bubble.from_dict(raw, bubble_id="b-bridge-ctx") + self.assertEqual( + bubble_context(raw, "b-bridge-ctx"), + {"fileSelections": [{"uri": {"path": "/a.py"}}]}, + ) + self.assertEqual(bubble.context, bubble_context(bubble, "b-bridge-ctx")) + + def test_dict_bridge_bubble_context_empty_when_key_missing(self) -> None: + with self.assertNoLogs("models.raw_access", level="WARNING"): + self.assertEqual(bubble_context({}, "b-no-ctx"), {}) + + def test_dict_bridge_attached_file_uris_skips_non_dict_elements(self) -> None: + raw = { + "attachedFileCodeChunksUris": [ + {"path": "/a.py"}, + "bad", + {"path": "/b.py"}, + ] + } + bubble = Bubble.from_dict(raw, bubble_id="b-bridge-uri") + with self.assertLogs("models.conversation", level="WARNING") as logs: + self.assertEqual( + bubble_attached_file_uris(raw, "b-bridge-uri"), + [{"path": "/a.py"}, {"path": "/b.py"}], + ) + self.assertEqual( + bubble.attached_file_code_chunks_uris, + bubble_attached_file_uris(bubble, "b-bridge-uri"), + ) + self.assertTrue( + any("attachedFileCodeChunksUris" in m for m in logs.output), + logs.output, + ) + def test_optional_raw_list_no_warning_when_present(self) -> None: with self.assertNoLogs("models.raw_access", level="WARNING"): value = optional_raw_list( diff --git a/utils/text_extract.py b/utils/text_extract.py index 93a01ca..c7b31aa 100644 --- a/utils/text_extract.py +++ b/utils/text_extract.py @@ -8,10 +8,9 @@ class HasBubbleRaw(Protocol): - """Bubble model or any object exposing a Cursor JSON ``raw`` dict.""" + """Bubble model or any object exposing stored Cursor JSON for text extraction.""" - @property - def raw(self) -> dict[str, Any]: ... + def cursor_storage_payload(self) -> dict[str, Any]: ... def extract_text_from_rich_text(children: list[Any]) -> str: @@ -33,7 +32,9 @@ def extract_text_from_rich_text(children: list[Any]) -> str: def extract_text_from_bubble(bubble: HasBubbleRaw | dict[str, Any]) -> str: """Extract displayable text from a bubble object (text, richText, codeBlocks).""" - payload: dict[str, Any] = bubble if isinstance(bubble, dict) else bubble.raw + payload: dict[str, Any] = ( + bubble if isinstance(bubble, dict) else bubble.cursor_storage_payload() + ) if not payload: return "" diff --git a/utils/tool_parser.py b/utils/tool_parser.py index de61892..59abaed 100644 --- a/utils/tool_parser.py +++ b/utils/tool_parser.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, Mapping def short_path(p: str) -> str: @@ -19,7 +19,7 @@ def short_path(p: str) -> str: return p -def parse_tool_call(tfd: dict[str, Any]) -> dict[str, str]: +def parse_tool_call(tfd: Mapping[str, Any]) -> dict[str, str]: """Parse toolFormerData into a structured tool call object with human-readable summaries.""" name = tfd.get("name") or "unknown" status = tfd.get("status") or ""