""" Tests for the ``conversation_labels`` table + store API (POLICIES.md §6, Phase 1 of the guardrails implementation). Covers the store-level contract (CRUD, batched UPSERT semantics, survival across conversation_items compaction, cascade delete). Schema validation (``values`` / ``monotonic`` checks) lives in the runtime engine, not the store — those tests land in Phase 3+. """ from __future__ import annotations import pytest from omnigent.entities import ( MessageData, NewConversationItem, ) from omnigent.stores.conversation_store.sqlalchemy_store import ( SqlAlchemyConversationStore, ) # ── Happy-path CRUD ──────────────────────────────────── def test_new_conversation_has_empty_labels( conversation_store: SqlAlchemyConversationStore, ) -> None: """A fresh conversation has no labels — empty dict, not None. If this regresses, downstream code that iterates ``conv.labels.items()`` would crash instead of no-op.""" conv = conversation_store.create_conversation() got = conversation_store.get_conversation(conv.id) # Exact equality (not truthiness) — regression guard # against returning None or sentinel. assert got is not None assert got.labels == {} def test_set_labels_persists_values( conversation_store: SqlAlchemyConversationStore, ) -> None: """Batched UPSERT writes every key; subsequent get reads them back. If this fails, the dialect-aware UPSERT path isn't running.""" conv = conversation_store.create_conversation() conversation_store.set_labels( conv.id, {"integrity": "1", "sensitivity": "public"}, ) got = conversation_store.get_conversation(conv.id) assert got is not None # Both keys must be present with exact values — the # assertion proves the string coercion + UPSERT round- # tripped without corrupting either payload. assert got.labels == {"integrity": "1", "sensitivity": "public"} def test_set_labels_overwrites_existing( conversation_store: SqlAlchemyConversationStore, ) -> None: """Second set_labels on the same key overwrites rather than errors or appends. Overwrite semantics are required by POLICIES.md §4 last-writer-wins composition.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1"}) conversation_store.set_labels(conv.id, {"integrity": "0"}) got = conversation_store.get_conversation(conv.id) assert got is not None # The later "0" won; if this reads "1", overwrite # semantics broke and the UPSERT turned into INSERT-only. assert got.labels == {"integrity": "0"} def test_set_labels_leaves_unmentioned_keys_untouched( conversation_store: SqlAlchemyConversationStore, ) -> None: """Keys not in the current update remain unchanged. If this regresses (e.g. a DELETE+INSERT implementation), every ``set_labels`` call would clobber the full state.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1", "sensitivity": "public"}) # Only update integrity; sensitivity must survive. conversation_store.set_labels(conv.id, {"integrity": "0"}) got = conversation_store.get_conversation(conv.id) assert got is not None # sensitivity still has its original value = proof the # UPSERT touched only the keys in the update dict. assert got.labels == {"integrity": "0", "sensitivity": "public"} def test_set_labels_empty_dict_is_noop( conversation_store: SqlAlchemyConversationStore, ) -> None: """Empty update is a no-op: no transaction, no state change. Guards against accidentally erasing labels when a policy returns no writes.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"x": "1"}) conversation_store.set_labels(conv.id, {}) got = conversation_store.get_conversation(conv.id) assert got is not None assert got.labels == {"x": "1"} def test_set_labels_clamps_overlong_value_to_column_width( conversation_store: SqlAlchemyConversationStore, ) -> None: """Values longer than the column width are clamped at the store chokepoint so no writer can raise ``DataError`` on PostgreSQL. SQLite (used here) doesn't enforce ``VARCHAR`` length, so this proves the Python-side clamp in ``_upsert_labels`` — not the DB — does the trimming, which is exactly what protects the Postgres production path. """ from omnigent.db.db_models import LABEL_VALUE_MAX_LEN conv = conversation_store.create_conversation() overlong = "z" * (LABEL_VALUE_MAX_LEN + 50) conversation_store.set_labels(conv.id, {"omnigent.last_task_error_message": overlong}) got = conversation_store.get_conversation(conv.id) assert got is not None stored = got.labels["omnigent.last_task_error_message"] assert len(stored) == LABEL_VALUE_MAX_LEN # Head preserved (the clamp keeps the front, not a tail or empty string). assert stored == overlong[:LABEL_VALUE_MAX_LEN] # Every label writer (client ``body.labels`` on create, policy writes, # session error labels) funnels through ``_upsert_labels``, so clamping # here is the single guarantee that the column can never overflow. def test_set_labels_many_keys_atomic( conversation_store: SqlAlchemyConversationStore, ) -> None: """All keys land in a single transaction. Concurrent readers should never see a partial write (not tested directly here — see concurrency test in Phase 2). Covers: N > 2 keys upserted together.""" conv = conversation_store.create_conversation() updates = {f"key_{i}": str(i) for i in range(20)} conversation_store.set_labels(conv.id, updates) got = conversation_store.get_conversation(conv.id) assert got is not None # All 20 keys present — partial commits would leave some # missing; transaction rollback would leave none. assert got.labels == updates # ── Survival across conversation_items churn ─────────── def test_labels_survive_item_appends( conversation_store: SqlAlchemyConversationStore, ) -> None: """Appending conversation_items does not touch labels. If this fails, a label set in turn 1 would vanish when the agent replies in turn 2 — critical invariant.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1"}) # Simulate a few turns of conversation. for i in range(5): conversation_store.append( conv.id, [ NewConversationItem( type="message", response_id=f"resp_{i}", data=MessageData( role="user", content=[{"type": "input_text", "text": f"turn {i}"}], ), ), ], ) got = conversation_store.get_conversation(conv.id) assert got is not None # Label state unchanged after 5 appends — the two tables # are truly independent. assert got.labels == {"integrity": "1"} def test_labels_survive_item_compaction_proxy( conversation_store: SqlAlchemyConversationStore, ) -> None: """Deleting (not re-appending) all conversation_items leaves labels intact. This is the Phase 1 proxy for POLICIES.md §6.3 compaction-survival: compaction rewrites items (effectively DELETE + INSERT), so verifying labels survive a bulk item delete is the same property at a lower layer. If this fails, ``conversation_labels`` has a non-obvious dependency on item rows — a hidden trigger, a cascade pointed the wrong way, or someone routed ``delete_items`` through the label table by mistake. """ from omnigent.db.db_models import SqlConversationItem conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1", "confidentiality": "0"}) # Add items, then nuke them to simulate compaction. conversation_store.append( conv.id, [ NewConversationItem( type="message", response_id="resp_0", data=MessageData( role="user", content=[{"type": "input_text", "text": "content"}], ), ), ], ) # Raw-SQL delete through the store's engine — matches # what a compaction implementation would do, minus the # summary-item replacement. Using the store's internal # session for test plumbing only; production code should # not reach past the public API. from sqlalchemy import delete with conversation_store._session() as session: session.execute( delete(SqlConversationItem).where( SqlConversationItem.conversation_id == conv.id, ) ) got = conversation_store.get_conversation(conv.id) assert got is not None # Labels persisted across the item-table wipe — matches # the §6.3 guarantee. assert got.labels == {"integrity": "1", "confidentiality": "0"} # ── Cascade delete ───────────────────────────────────── @pytest.mark.asyncio async def test_delete_conversation_cascades_to_labels( conversation_store: SqlAlchemyConversationStore, ) -> None: """When the conversation goes away, its labels go too (FK ON DELETE CASCADE). Without cascade, the labels table would accumulate orphaned rows forever.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1"}) # Pre-condition: label is present. assert (conversation_store.get_conversation(conv.id) or conv).labels == { "integrity": "1", } # Act: delete the conversation (async, returns True on # existence). deleted = await conversation_store.delete_conversation(conv.id) assert deleted is True # Post-condition: no conversation → no labels to fetch. # We verify via a direct count rather than reading via # get_conversation (which now returns None and gives us # no label snapshot). from sqlalchemy import func, select from omnigent.db.db_models import SqlConversationLabel with conversation_store._session() as session: count = session.execute( select(func.count()) .select_from(SqlConversationLabel) .where( SqlConversationLabel.conversation_id == conv.id, ) ).scalar() # FK cascade dropped the rows along with the parent; if # this reads > 0, the FK was not declared ON DELETE # CASCADE (see the migration). assert count == 0 # ── Isolation across conversations ───────────────────── def test_labels_isolated_across_conversations( conversation_store: SqlAlchemyConversationStore, ) -> None: """Writes on one conversation do not leak to another. Guards against a missing ``conversation_id`` filter in either the read path or the write path.""" a = conversation_store.create_conversation() b = conversation_store.create_conversation() conversation_store.set_labels(a.id, {"integrity": "0"}) conversation_store.set_labels(b.id, {"integrity": "1"}) got_a = conversation_store.get_conversation(a.id) got_b = conversation_store.get_conversation(b.id) assert got_a is not None and got_b is not None # Each conversation has its own label state — proves # both SELECT and UPSERT filter by conversation_id. assert got_a.labels == {"integrity": "0"} assert got_b.labels == {"integrity": "1"} # ── get_conversation semantics ───────────────────────── def test_get_conversation_missing_returns_none( conversation_store: SqlAlchemyConversationStore, ) -> None: """get_conversation on a non-existent ID returns None, not a zero-value Conversation. Must not attempt the label fetch for a missing parent (no SELECT error).""" got = conversation_store.get_conversation("conv_does_not_exist") assert got is None # ── list_conversations + update_conversation parity ── def test_list_conversations_populates_labels( conversation_store: SqlAlchemyConversationStore, ) -> None: """`list_conversations` populates the `labels` field on each returned Conversation. Without this, UI code paging through conversations would see empty labels where they exist (silent data loss). Verifies the bulk-fetch path.""" a = conversation_store.create_conversation() b = conversation_store.create_conversation() c = conversation_store.create_conversation() conversation_store.set_labels(a.id, {"integrity": "0"}) conversation_store.set_labels(c.id, {"confidentiality": "1"}) # b has no labels. page = conversation_store.list_conversations(limit=10) by_id = {conv.id: conv for conv in page.data} # Every returned conversation has its labels populated # correctly; conversations with no labels get empty # dicts (not None). Proves the bulk fetcher handles # sparse label rows. assert by_id[a.id].labels == {"integrity": "0"} assert by_id[b.id].labels == {} assert by_id[c.id].labels == {"confidentiality": "1"} def test_update_conversation_returns_labels( conversation_store: SqlAlchemyConversationStore, ) -> None: """After update_conversation, the returned Conversation has labels populated — parity with get_conversation. Callers mixing update_conversation and label inspection must not get stale empty labels.""" conv = conversation_store.create_conversation() conversation_store.set_labels(conv.id, {"integrity": "1"}) updated = conversation_store.update_conversation(conv.id, title="Renamed") assert updated is not None assert updated.title == "Renamed" # Proves the label fetch runs in the update path too — # not just in get_conversation. assert updated.labels == {"integrity": "1"} # ── Caller-supplied updated_at ───────────────────────── def test_set_labels_honors_caller_timestamp( conversation_store: SqlAlchemyConversationStore, ) -> None: """When the caller passes an explicit `updated_at`, the store records that exact value. Required for the policy engine to align audit timestamps with evaluation time rather than wall-clock drift between evaluate() and the DB write (POLICIES.md §6.3). If this regresses, the timestamp would come from `now_epoch()` regardless, and replay / audit would show inconsistent stamps. """ from sqlalchemy import select from omnigent.db.db_models import SqlConversationLabel conv = conversation_store.create_conversation() caller_stamp = 1_700_000_042 # arbitrary historical epoch conversation_store.set_labels(conv.id, {"integrity": "1"}, updated_at=caller_stamp) with conversation_store._session() as session: row = session.execute( select(SqlConversationLabel.updated_at).where( SqlConversationLabel.conversation_id == conv.id, SqlConversationLabel.key == "integrity", ) ).scalar() # Store persisted the caller's timestamp verbatim — if # this shows `now_epoch()`, the override isn't wired. assert row == caller_stamp def test_upsert_refreshes_timestamp_on_overwrite( conversation_store: SqlAlchemyConversationStore, ) -> None: """UPSERT must refresh the timestamp column on re-write even when the value is unchanged — matches the SqlConversationLabel docstring invariant + POLICIES.md audit requirement. Using caller-supplied timestamps to deterministically prove the refresh (wall-clock epoch- second resolution would let a broken UPSERT pass this test if both writes landed in the same second). If this regresses, the ``on_conflict_do_update`` ``set_`` clause dropped ``updated_at`` and old stamps would persist forever — audit timelines would silently skew. """ from sqlalchemy import select from omnigent.db.db_models import SqlConversationLabel conv = conversation_store.create_conversation() # Two deliberate timestamps far enough apart that any # bug failing to update `updated_at` would leave the # earlier value behind. first_stamp = 1_700_000_000 second_stamp = 1_700_000_999 conversation_store.set_labels( conv.id, {"x": "1"}, updated_at=first_stamp, ) conversation_store.set_labels( conv.id, {"x": "2"}, updated_at=second_stamp, ) with conversation_store._session() as session: stamp = session.execute( select(SqlConversationLabel.updated_at).where( SqlConversationLabel.conversation_id == conv.id, SqlConversationLabel.key == "x", ) ).scalar() # Must be the LATER stamp — proves UPSERT updated both # `value` and `updated_at`. If this reads first_stamp, # the `set_` clause forgot `updated_at`. assert stamp == second_stamp