virtual-context 0.3.8__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {virtual_context-0.3.8 → virtual_context-0.4.2}/.gitignore +10 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/PKG-INFO +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/pyproject.toml +10 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_compaction.py +3 -3
- virtual_context-0.4.2/tests/test_active_compaction_op_invariants.py +172 -0
- virtual_context-0.4.2/tests/test_canonical_row_epoch_guards.py +224 -0
- virtual_context-0.4.2/tests/test_canonical_row_epoch_guards_postgres.py +200 -0
- virtual_context-0.4.2/tests/test_canonical_turn_grouping.py +206 -0
- virtual_context-0.4.2/tests/test_canonical_turns_schema.py +32 -0
- virtual_context-0.4.2/tests/test_canonical_turns_schema_postgres.py +47 -0
- virtual_context-0.4.2/tests/test_claim_compaction_lease_signature.py +147 -0
- virtual_context-0.4.2/tests/test_claim_compaction_lease_signature_postgres.py +114 -0
- virtual_context-0.4.2/tests/test_cleanup_abandoned_compaction.py +329 -0
- virtual_context-0.4.2/tests/test_cleanup_abandoned_compaction_postgres.py +381 -0
- virtual_context-0.4.2/tests/test_compacting_phase_takeover.py +834 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compaction_commit_prune.py +3 -23
- virtual_context-0.4.2/tests/test_compaction_heartbeat_sidecar.py +139 -0
- virtual_context-0.4.2/tests/test_compaction_lease_lost_abort.py +54 -0
- virtual_context-0.4.2/tests/test_compaction_lifecycle.py +170 -0
- virtual_context-0.4.2/tests/test_compaction_lifecycle_contract.py +69 -0
- virtual_context-0.4.2/tests/test_compaction_lifecycle_wiring.py +167 -0
- virtual_context-0.4.2/tests/test_compaction_operation.py +80 -0
- virtual_context-0.4.2/tests/test_compaction_operation_crud.py +396 -0
- virtual_context-0.4.2/tests/test_compaction_operation_crud_postgres.py +400 -0
- virtual_context-0.4.2/tests/test_compaction_operation_id_uuid.py +94 -0
- virtual_context-0.4.2/tests/test_compaction_operation_postgres.py +50 -0
- virtual_context-0.4.2/tests/test_compaction_ownership_guards.py +742 -0
- virtual_context-0.4.2/tests/test_compaction_progress_event.py +84 -0
- virtual_context-0.4.2/tests/test_compaction_resume_e2e_postgres.py +342 -0
- virtual_context-0.4.2/tests/test_compaction_resume_schema_migration.py +99 -0
- virtual_context-0.4.2/tests/test_compaction_resume_schema_migration_postgres.py +173 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_composite_store.py +12 -2
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_coverage_report.py +44 -2
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_lifecycle.py +7 -5
- virtual_context-0.4.2/tests/test_conversation_schema.py +19 -0
- virtual_context-0.4.2/tests/test_conversation_schema_postgres.py +35 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_deferred_payload_compaction.py +174 -77
- virtual_context-0.4.2/tests/test_delete_resurrect_epoch_races.py +179 -0
- virtual_context-0.4.2/tests/test_engine_event_bus.py +65 -0
- virtual_context-0.4.2/tests/test_engine_lifecycle_epoch.py +146 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_state.py +210 -40
- virtual_context-0.4.2/tests/test_engine_sync_turns.py +844 -0
- virtual_context-0.4.2/tests/test_filesystem_store_compaction_compat.py +172 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_find_quote.py +26 -26
- virtual_context-0.4.2/tests/test_find_stale_lease_helpers.py +192 -0
- virtual_context-0.4.2/tests/test_handle_prepare_payload.py +707 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_history_filter.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_ingest_index_integrity.py +8 -8
- virtual_context-0.4.2/tests/test_ingest_reconciler_append_fastpath.py +390 -0
- virtual_context-0.4.2/tests/test_ingest_reconciler_epoch.py +142 -0
- virtual_context-0.4.2/tests/test_ingestion_compaction_full_cycle.py +227 -0
- virtual_context-0.4.2/tests/test_ingestion_db_sweep_catchup.py +222 -0
- virtual_context-0.4.2/tests/test_ingestion_episode.py +74 -0
- virtual_context-0.4.2/tests/test_ingestion_episode_crud.py +198 -0
- virtual_context-0.4.2/tests/test_ingestion_episode_crud_postgres.py +301 -0
- virtual_context-0.4.2/tests/test_ingestion_episode_postgres.py +50 -0
- virtual_context-0.4.2/tests/test_ingestion_finalize_self_heal.py +291 -0
- virtual_context-0.4.2/tests/test_ingestion_progress_event.py +75 -0
- virtual_context-0.4.2/tests/test_ingestion_regression_resume.py +471 -0
- virtual_context-0.4.2/tests/test_legacy_ingestion_lease_ownership.py +1095 -0
- virtual_context-0.4.2/tests/test_lifecycle_epoch.py +47 -0
- virtual_context-0.4.2/tests/test_lifecycle_epoch_store.py +221 -0
- virtual_context-0.4.2/tests/test_lifecycle_epoch_store_postgres.py +290 -0
- virtual_context-0.4.2/tests/test_mark_canonical_turns_compacted_all_halves.py +194 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_monitor.py +1 -1
- virtual_context-0.4.2/tests/test_multi_worker_sliding_window.py +247 -0
- virtual_context-0.4.2/tests/test_neo4j_falkordb_compaction_compat.py +81 -0
- virtual_context-0.4.2/tests/test_orphan_compacting_repair.py +102 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_paging.py +10 -10
- virtual_context-0.4.2/tests/test_phase_transition_events.py +112 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_postgres_store.py +75 -0
- virtual_context-0.4.2/tests/test_progress_event_bus.py +91 -0
- virtual_context-0.4.2/tests/test_progress_events.py +74 -0
- virtual_context-0.4.2/tests/test_progress_snapshot.py +202 -0
- virtual_context-0.4.2/tests/test_progress_snapshot_postgres.py +334 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy.py +175 -5
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_dashboard.py +6 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_message_filter.py +6 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_session.py +277 -8
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_streaming.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_raw_content.py +14 -17
- virtual_context-0.4.2/tests/test_refresh_compaction_heartbeat.py +85 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_request_captures_persistence.py +110 -18
- virtual_context-0.4.2/tests/test_request_metadata.py +187 -0
- virtual_context-0.4.2/tests/test_request_metadata_postgres.py +196 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_semantic_search.py +7 -7
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_sender_identity.py +2 -2
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_date.py +109 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_state.py +31 -3
- virtual_context-0.4.2/tests/test_sqlite_schema_add_column_race.py +99 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_store_recovery.py +9 -8
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_splitter.py +4 -4
- virtual_context-0.4.2/tests/test_tagger_loop.py +231 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_loop.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_result_filter.py +7 -8
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_tags.py +4 -4
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_turn_tag_index.py +2 -2
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_vcattach.py +20 -9
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/__init__.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/cli/main.py +2 -3
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/assembler.py +6 -1
- virtual_context-0.4.2/virtual_context/core/canonical_turns.py +96 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/compaction_pipeline.py +253 -124
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/compactor.py +18 -2
- virtual_context-0.4.2/virtual_context/core/composite_store.py +1096 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/conversation_store.py +10 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/coverage_report.py +12 -2
- virtual_context-0.4.2/virtual_context/core/event_bus.py +43 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/hint_builder.py +10 -11
- virtual_context-0.4.2/virtual_context/core/ingest_reconciler.py +844 -0
- virtual_context-0.4.2/virtual_context/core/lifecycle_epoch.py +25 -0
- virtual_context-0.4.2/virtual_context/core/progress_events.py +37 -0
- virtual_context-0.4.2/virtual_context/core/progress_snapshot.py +40 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/protocols.py +109 -24
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/provider_adapters.py +12 -4
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/quote_search.py +12 -29
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retrieval_assembler.py +9 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/search_engine.py +3 -3
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/segmenter.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/semantic_search.py +26 -36
- virtual_context-0.4.2/virtual_context/core/store.py +1112 -0
- virtual_context-0.4.2/virtual_context/core/tagging_pipeline.py +1514 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tool_query.py +1 -1
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/turn_tag_index.py +38 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/engine.py +524 -299
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/dashboard.html +7 -7
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/dashboard.py +8 -8
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/formats.py +237 -115
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/handlers.py +37 -19
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/helpers.py +17 -6
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/message_filter.py +193 -27
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/metrics.py +29 -8
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/server.py +221 -61
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/session_state.py +65 -21
- virtual_context-0.4.2/virtual_context/proxy/state.py +3854 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/falkordb.py +18 -2
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/filesystem.py +37 -129
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/neo4j.py +18 -2
- virtual_context-0.4.2/virtual_context/storage/postgres.py +5302 -0
- virtual_context-0.4.2/virtual_context/storage/sqlite.py +5987 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/types.py +105 -29
- virtual_context-0.3.8/.github/workflows/python-publish.yml +0 -37
- virtual_context-0.3.8/CLAUDE.md +0 -16
- virtual_context-0.3.8/specs/cache-aware-compaction.md +0 -573
- virtual_context-0.3.8/tests/test_engine_sync_turns.py +0 -195
- virtual_context-0.3.8/tests/test_full_text_backfill.py +0 -478
- virtual_context-0.3.8/virtual-context.yaml +0 -105
- virtual_context-0.3.8/virtual_context/core/composite_store.py +0 -546
- virtual_context-0.3.8/virtual_context/core/full_text_backfill.py +0 -691
- virtual_context-0.3.8/virtual_context/core/store.py +0 -627
- virtual_context-0.3.8/virtual_context/core/tagging_pipeline.py +0 -907
- virtual_context-0.3.8/virtual_context/proxy/state.py +0 -1967
- virtual_context-0.3.8/virtual_context/storage/postgres.py +0 -2900
- virtual_context-0.3.8/virtual_context/storage/sqlite.py +0 -3676
- {virtual_context-0.3.8 → virtual_context-0.4.2}/LICENSE +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/README.md +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/assets/dashboard.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/assets/hero.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/models.yaml +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/scripts/install.ps1 +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/scripts/install.sh +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/REGRESSION_MAP.md +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/conftest.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/docker-compose.test.yml +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/conftest.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_retrieval.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/haiku/test_tagging.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/conftest.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_compactor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_pipeline.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_provider.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/ollama/test_tag_generator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/test_dashboard_cors.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/proxy/test_metrics.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_assembler.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_backend_integration.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_beam_judge.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_budget_enforcement.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_cli_init.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compactor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_compactor_concurrent.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_config.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_context_bleed.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_identity.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_conversation_scoping.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_embedding_tag_generator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_empty_turn_skip.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_integration.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_engine_lookback.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_enrichment.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_graph_integration.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_checker.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_query.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_link_types.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_links_sqlite.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fact_redesign.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_fill_pass.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_format_agnostic.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_headless.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_idf_retrieval.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_longmemeval_auth.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_mcp_server.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_media.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_message_filter.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_metrics_persistence.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_model_catalog.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_model_limits.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_multi_instance.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_noop_fact_link_store.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_openrouter_provider.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_passthrough_filter.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_presets.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_prev_context_leak.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_provider_adapters.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_proxy_formats.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_recall_all.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_registry_lifecycle.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_retriever.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_rrf_scoring.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_segmenter.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_session_cache.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_storage_protocols.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_store_sqlite.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_stub_turn_handling.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_supersession.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_supersession_migration.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_canonicalizer.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_consolidator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tag_generator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_telemetry.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_telemetry_integration.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_temporal_resolver.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tool_output_interceptor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_tui.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_turn_grouping.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_unified_budget.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_upstream_trim.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/tests/test_verb_expansion.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual-context.yaml.example +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/cli/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/config.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/conversation_identity.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/embedding_provider.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/embedding_tag_generator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/engine_utils.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/fact_query.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/fts_preprocessor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/llm_utils.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/math_utils.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/model_catalog.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/monitor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/paging_manager.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retrieval_scoring.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/retriever.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_canonicalizer.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_consolidator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_generator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_scoring.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tag_splitter.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/telemetry.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/temporal_resolver.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/core/tool_loop.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/data/anthropic-tokenizer/tokenizer.json +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/data/models.yaml +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/curator.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/date_resolver.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/parsers.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/ingest/supersession.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/mcp/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/mcp/server.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/model_limits.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/openclaw/virtual-context.mjs +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/patterns.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/agentic.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/base.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/presets/coding.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/anthropic.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/base.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/generic_openai.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/providers/ollama_native.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/_envelope.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/media.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/multi.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/registry.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/session_cache.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/android-chrome-192x192.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/android-chrome-512x512.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/apple-touch-icon.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon-16x16.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon-32x32.png +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/favicon.ico +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/static/site.webmanifest +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/tool_output_interceptor.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/proxy/vcattach.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/helpers.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/storage/noop_fact_link_store.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/token_counter.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/app.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/chat.tcss +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/chat_provider.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/headless.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/modals/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/modals/turn_inspector.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/state.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/__init__.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/budget_bar.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/chat_view.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/input_box.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/tag_panel.py +0 -0
- {virtual_context-0.3.8 → virtual_context-0.4.2}/virtual_context/tui/widgets/turn_list.py +0 -0
|
@@ -8,6 +8,9 @@ build/
|
|
|
8
8
|
# Virtual environment
|
|
9
9
|
.venv/
|
|
10
10
|
|
|
11
|
+
# Infrastructure configs (contains secrets — never commit)
|
|
12
|
+
infrastructure/
|
|
13
|
+
|
|
11
14
|
# Testing
|
|
12
15
|
.pytest_cache/
|
|
13
16
|
|
|
@@ -27,15 +30,22 @@ scripts/*
|
|
|
27
30
|
!scripts/install.ps1
|
|
28
31
|
!scripts/install.sh
|
|
29
32
|
|
|
33
|
+
# Environment files (API keys)
|
|
34
|
+
.env
|
|
35
|
+
|
|
30
36
|
# Config files (may contain API keys)
|
|
31
37
|
virtual-context-*.yaml
|
|
32
38
|
!virtual-context.yaml.example
|
|
33
39
|
|
|
34
40
|
# Internal docs
|
|
35
41
|
CHANGELOG.md
|
|
42
|
+
CLAUDE.md
|
|
36
43
|
docs/superpowers/
|
|
37
44
|
docs/competitive-analysis.md
|
|
38
45
|
diagnostics/
|
|
46
|
+
specs/
|
|
47
|
+
tests_timing_report.md
|
|
48
|
+
tests_by_section.sh
|
|
39
49
|
uv.lock
|
|
40
50
|
extraneous/paper/virtual_context_paper_latest.md
|
|
41
51
|
extraneous/paper/data/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtual-context
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: OS-style virtual memory for LLM session context management
|
|
5
5
|
Project-URL: Homepage, https://virtual-context.com
|
|
6
6
|
Project-URL: Repository, https://github.com/virtual-context/virtual-context
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "virtual-context"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "OS-style virtual memory for LLM session context management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "AGPL-3.0-or-later"
|
|
@@ -67,6 +67,15 @@ exclude = [
|
|
|
67
67
|
".virtualcontext/",
|
|
68
68
|
"*.db",
|
|
69
69
|
"*.log",
|
|
70
|
+
# Internal / private — never ship in sdist
|
|
71
|
+
"CLAUDE.md",
|
|
72
|
+
"tests_timing_report.md",
|
|
73
|
+
"tests_by_section.sh",
|
|
74
|
+
"specs/",
|
|
75
|
+
"virtual-context.yaml",
|
|
76
|
+
".github/",
|
|
77
|
+
".gitignore",
|
|
78
|
+
".worktrees/",
|
|
70
79
|
]
|
|
71
80
|
artifacts = [
|
|
72
81
|
"virtual_context/proxy/static/*",
|
|
@@ -94,8 +94,8 @@ class TestCompactionCycle:
|
|
|
94
94
|
history = _simulate_turns(engine, pairs)
|
|
95
95
|
|
|
96
96
|
# Compaction should have fired
|
|
97
|
-
assert engine._engine_state.
|
|
98
|
-
f"Expected compaction, but _compacted_through={engine._engine_state.
|
|
97
|
+
assert engine._engine_state.compacted_prefix_messages > 0, (
|
|
98
|
+
f"Expected compaction, but _compacted_through={engine._engine_state.compacted_prefix_messages}"
|
|
99
99
|
)
|
|
100
100
|
|
|
101
101
|
# Post-compaction retrieval should still work
|
|
@@ -204,7 +204,7 @@ class TestCompactionCycle:
|
|
|
204
204
|
history = _simulate_turns(engine, pairs)
|
|
205
205
|
|
|
206
206
|
# Should have compacted multiple times
|
|
207
|
-
assert engine._engine_state.
|
|
207
|
+
assert engine._engine_state.compacted_prefix_messages > 0
|
|
208
208
|
|
|
209
209
|
# Engine should still respond without errors
|
|
210
210
|
result = engine.on_message_inbound("What topics did we cover?", history)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Task 16: ProxyState._active_compaction_op four-site lifecycle invariant.
|
|
2
|
+
|
|
3
|
+
Tests:
|
|
4
|
+
18a: test_takeover_sets_active_compaction_op_before_submit
|
|
5
|
+
18b: test_wrapper_finally_clears_active_compaction_op_on_normal_exit
|
|
6
|
+
18c: test_wrapper_finally_clears_active_compaction_op_on_exception
|
|
7
|
+
18d: test_submit_exception_clears_active_compaction_op (regression guard)
|
|
8
|
+
|
|
9
|
+
Test 18e (same-worker re-enter after submit exception) is deferred to
|
|
10
|
+
tests/test_compacting_phase_takeover.py (Task 18) because it requires the
|
|
11
|
+
handle_prepare_payload compacting-branch takeover wiring that Task 18 adds.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
|
|
17
|
+
|
|
18
|
+
from concurrent.futures import Future
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from unittest.mock import MagicMock, patch
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# 18a: _submit_compaction_request sets _active_compaction_op BEFORE submitting
|
|
27
|
+
# the future when preexisting_operation_id is provided.
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def test_takeover_sets_active_compaction_op_before_submit(tmp_path: Path):
|
|
31
|
+
"""_active_compaction_op must be set to preexisting_operation_id before
|
|
32
|
+
the thread-pool submit call so the takeover predicate is already live if
|
|
33
|
+
a concurrent worker checks between set and submit.
|
|
34
|
+
"""
|
|
35
|
+
from tests.test_handle_prepare_payload import _make_proxy_state
|
|
36
|
+
|
|
37
|
+
state = _make_proxy_state(tmp_path)
|
|
38
|
+
|
|
39
|
+
captured_op_at_submit: list[str | None] = []
|
|
40
|
+
|
|
41
|
+
original_submit = state._compact_pool.submit
|
|
42
|
+
|
|
43
|
+
def capturing_submit(fn, *args, **kwargs):
|
|
44
|
+
# Capture the value of _active_compaction_op at the moment submit fires
|
|
45
|
+
captured_op_at_submit.append(state._active_compaction_op)
|
|
46
|
+
# Return a done future so the wrapper doesn't block
|
|
47
|
+
f: Future[None] = Future()
|
|
48
|
+
f.set_result(None)
|
|
49
|
+
return f
|
|
50
|
+
|
|
51
|
+
with patch.object(state._compact_pool, "submit", side_effect=capturing_submit), \
|
|
52
|
+
patch.object(state, "_run_compact_wrapper"):
|
|
53
|
+
state._submit_compaction_request(
|
|
54
|
+
history=[],
|
|
55
|
+
signal=None,
|
|
56
|
+
turn=0,
|
|
57
|
+
target_end=5,
|
|
58
|
+
turn_id="",
|
|
59
|
+
preexisting_operation_id="op-abc-123",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert len(captured_op_at_submit) == 1, "submit must be called exactly once"
|
|
63
|
+
assert captured_op_at_submit[0] == "op-abc-123", (
|
|
64
|
+
f"_active_compaction_op must equal preexisting_operation_id at submit time, "
|
|
65
|
+
f"got {captured_op_at_submit[0]!r}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# 18b: _run_compact_wrapper finally clears _active_compaction_op on normal exit
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def test_wrapper_finally_clears_active_compaction_op_on_normal_exit(tmp_path: Path):
|
|
74
|
+
"""After _run_compact_wrapper returns normally, _active_compaction_op must
|
|
75
|
+
be None regardless of what it was set to before the call.
|
|
76
|
+
"""
|
|
77
|
+
from tests.test_handle_prepare_payload import _make_proxy_state
|
|
78
|
+
|
|
79
|
+
state = _make_proxy_state(tmp_path)
|
|
80
|
+
state._active_compaction_op = "op-should-be-cleared"
|
|
81
|
+
|
|
82
|
+
with patch.object(state, "_run_compact", return_value=None):
|
|
83
|
+
state._run_compact_wrapper(
|
|
84
|
+
history=[],
|
|
85
|
+
signal=None,
|
|
86
|
+
turn=0,
|
|
87
|
+
target_end=5,
|
|
88
|
+
turn_id="",
|
|
89
|
+
preexisting_operation_id="op-should-be-cleared",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert state._active_compaction_op is None, (
|
|
93
|
+
f"_active_compaction_op must be None after wrapper normal exit, "
|
|
94
|
+
f"got {state._active_compaction_op!r}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# 18c: _run_compact_wrapper finally clears _active_compaction_op on exception
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def test_wrapper_finally_clears_active_compaction_op_on_exception(tmp_path: Path):
|
|
103
|
+
"""Even when _run_compact raises, _run_compact_wrapper's finally block must
|
|
104
|
+
clear _active_compaction_op so future takeover checks are not blocked.
|
|
105
|
+
"""
|
|
106
|
+
from tests.test_handle_prepare_payload import _make_proxy_state
|
|
107
|
+
|
|
108
|
+
state = _make_proxy_state(tmp_path)
|
|
109
|
+
state._active_compaction_op = "op-raise-case"
|
|
110
|
+
|
|
111
|
+
with patch.object(state, "_run_compact", side_effect=RuntimeError("boom")):
|
|
112
|
+
# The wrapper must NOT re-raise (it already swallows exceptions via
|
|
113
|
+
# the existing try/finally structure — the finally still runs).
|
|
114
|
+
try:
|
|
115
|
+
state._run_compact_wrapper(
|
|
116
|
+
history=[],
|
|
117
|
+
signal=None,
|
|
118
|
+
turn=0,
|
|
119
|
+
target_end=5,
|
|
120
|
+
turn_id="",
|
|
121
|
+
preexisting_operation_id="op-raise-case",
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
# Whether the wrapper propagates or swallows, _active_compaction_op
|
|
125
|
+
# must be None after the call returns/raises.
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
assert state._active_compaction_op is None, (
|
|
129
|
+
f"_active_compaction_op must be None after wrapper exception exit, "
|
|
130
|
+
f"got {state._active_compaction_op!r}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# 18d: submit exception clears _active_compaction_op (CRITICAL regression guard)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def test_submit_exception_clears_active_compaction_op(tmp_path: Path):
|
|
139
|
+
"""If the thread-pool submit() call itself raises (e.g. pool shutdown),
|
|
140
|
+
_submit_compaction_request must clear _active_compaction_op back to None
|
|
141
|
+
so subsequent requests are not permanently blocked by a stale op-id.
|
|
142
|
+
|
|
143
|
+
This is the regression guard: without the except-clause cleanup, a pool
|
|
144
|
+
shutdown exception would leave _active_compaction_op set forever and every
|
|
145
|
+
future takeover check would see a non-None value for an operation that
|
|
146
|
+
never ran.
|
|
147
|
+
"""
|
|
148
|
+
from tests.test_handle_prepare_payload import _make_proxy_state
|
|
149
|
+
|
|
150
|
+
state = _make_proxy_state(tmp_path)
|
|
151
|
+
|
|
152
|
+
assert state._active_compaction_op is None, "pre-condition: starts as None"
|
|
153
|
+
|
|
154
|
+
with patch.object(
|
|
155
|
+
state._compact_pool,
|
|
156
|
+
"submit",
|
|
157
|
+
side_effect=RuntimeError("pool is shut down"),
|
|
158
|
+
):
|
|
159
|
+
with pytest.raises(RuntimeError, match="pool is shut down"):
|
|
160
|
+
state._submit_compaction_request(
|
|
161
|
+
history=[],
|
|
162
|
+
signal=None,
|
|
163
|
+
turn=0,
|
|
164
|
+
target_end=5,
|
|
165
|
+
turn_id="",
|
|
166
|
+
preexisting_operation_id="op-submit-fail",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert state._active_compaction_op is None, (
|
|
170
|
+
f"_active_compaction_op must be reset to None after submit() raises, "
|
|
171
|
+
f"got {state._active_compaction_op!r}"
|
|
172
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Tests for epoch-guarded ``iter_untagged_canonical_rows`` +
|
|
2
|
+
``mark_canonical_row_tagged`` on the SQLite backend.
|
|
3
|
+
|
|
4
|
+
Both methods scope their SQL to ``conversations.lifecycle_epoch`` so a stale
|
|
5
|
+
caller (whose in-memory epoch no longer matches the authoritative row) sees
|
|
6
|
+
zero rows on iter and a ``False`` return on mark. This keeps concurrent
|
|
7
|
+
delete/resurrect workflows from letting a background tagger touch rows that
|
|
8
|
+
belong to a new lifecycle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from virtual_context.storage.sqlite import SQLiteStore
|
|
16
|
+
from virtual_context.core.canonical_turns import utcnow_iso
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _seed_canonical_row(
|
|
20
|
+
s: SQLiteStore,
|
|
21
|
+
*,
|
|
22
|
+
conv_id: str,
|
|
23
|
+
canonical_id: str,
|
|
24
|
+
sort_key: float,
|
|
25
|
+
tagged: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
now = utcnow_iso()
|
|
28
|
+
with s._get_conn() as conn:
|
|
29
|
+
conn.execute(
|
|
30
|
+
"""
|
|
31
|
+
INSERT INTO canonical_turns (
|
|
32
|
+
canonical_turn_id, conversation_id, turn_hash, hash_version,
|
|
33
|
+
normalized_user_text, normalized_assistant_text,
|
|
34
|
+
user_content, assistant_content,
|
|
35
|
+
sort_key, source_batch_id, first_seen_at, last_seen_at,
|
|
36
|
+
covered_ingestible_entries, tagged_at,
|
|
37
|
+
created_at, updated_at
|
|
38
|
+
) VALUES (?, ?, ?, 1, 'u','a','u_raw','a_raw', ?, 'b', ?, ?, 1, ?, ?, ?)
|
|
39
|
+
""",
|
|
40
|
+
(
|
|
41
|
+
canonical_id,
|
|
42
|
+
conv_id,
|
|
43
|
+
f"h_{canonical_id}",
|
|
44
|
+
sort_key,
|
|
45
|
+
now,
|
|
46
|
+
now,
|
|
47
|
+
now if tagged else None,
|
|
48
|
+
now,
|
|
49
|
+
now,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_iter_untagged_returns_untagged_in_sort_key_order(tmp_path: Path) -> None:
|
|
55
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
56
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
57
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=3000.0)
|
|
58
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t1", sort_key=1000.0)
|
|
59
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t2", sort_key=2000.0)
|
|
60
|
+
rows = s.iter_untagged_canonical_rows(
|
|
61
|
+
conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
|
|
62
|
+
)
|
|
63
|
+
ids_in_order = [r.canonical_turn_id for r in rows]
|
|
64
|
+
assert ids_in_order == ["t1", "t2", "t0"] # sorted by sort_key
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_iter_untagged_respects_batch_size(tmp_path: Path) -> None:
|
|
68
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
69
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
70
|
+
for i in range(5):
|
|
71
|
+
_seed_canonical_row(s, conv_id="c", canonical_id=f"t{i}", sort_key=float(i * 1000))
|
|
72
|
+
rows = s.iter_untagged_canonical_rows(
|
|
73
|
+
conversation_id="c", expected_lifecycle_epoch=1, batch_size=2,
|
|
74
|
+
)
|
|
75
|
+
assert len(rows) == 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_iter_untagged_excludes_tagged_rows(tmp_path: Path) -> None:
|
|
79
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
80
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
81
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t_tagged", sort_key=1000.0, tagged=True)
|
|
82
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t_pending", sort_key=2000.0, tagged=False)
|
|
83
|
+
rows = s.iter_untagged_canonical_rows(
|
|
84
|
+
conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
|
|
85
|
+
)
|
|
86
|
+
assert [r.canonical_turn_id for r in rows] == ["t_pending"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_iter_untagged_returns_empty_on_epoch_mismatch(tmp_path: Path) -> None:
|
|
90
|
+
"""SQL-level epoch guard. Stale-epoch caller sees no rows even if rows exist."""
|
|
91
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
92
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
93
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0)
|
|
94
|
+
s.mark_conversation_deleted("c")
|
|
95
|
+
s.increment_lifecycle_epoch_on_resurrect("c") # epoch=2
|
|
96
|
+
# Seed a new-lifecycle row.
|
|
97
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t_new", sort_key=2000.0)
|
|
98
|
+
# Stale caller: epoch=1.
|
|
99
|
+
rows = s.iter_untagged_canonical_rows(
|
|
100
|
+
conversation_id="c", expected_lifecycle_epoch=1, batch_size=10,
|
|
101
|
+
)
|
|
102
|
+
assert rows == [] # sees nothing
|
|
103
|
+
# Current caller: epoch=2 — sees both (t0 from old lifecycle not purged, plus t_new).
|
|
104
|
+
rows = s.iter_untagged_canonical_rows(
|
|
105
|
+
conversation_id="c", expected_lifecycle_epoch=2, batch_size=10,
|
|
106
|
+
)
|
|
107
|
+
assert len(rows) == 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_mark_canonical_row_tagged_sets_timestamp(tmp_path: Path) -> None:
|
|
111
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
112
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
113
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t_pending", sort_key=1000.0)
|
|
114
|
+
ok = s.mark_canonical_row_tagged(
|
|
115
|
+
canonical_turn_id="t_pending", conversation_id="c",
|
|
116
|
+
expected_lifecycle_epoch=1,
|
|
117
|
+
)
|
|
118
|
+
assert ok is True
|
|
119
|
+
with s._get_conn() as conn:
|
|
120
|
+
row = conn.execute(
|
|
121
|
+
"SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id='t_pending'"
|
|
122
|
+
).fetchone()
|
|
123
|
+
assert row[0] is not None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_mark_canonical_row_tagged_rejects_on_epoch_mismatch(tmp_path: Path) -> None:
|
|
127
|
+
"""Proves SQL-level epoch guard on the mark boundary."""
|
|
128
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
129
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
130
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0)
|
|
131
|
+
s.mark_conversation_deleted("c")
|
|
132
|
+
s.increment_lifecycle_epoch_on_resurrect("c") # epoch=2
|
|
133
|
+
# Stale caller tries to mark a row that still exists.
|
|
134
|
+
ok = s.mark_canonical_row_tagged(
|
|
135
|
+
canonical_turn_id="t0", conversation_id="c",
|
|
136
|
+
expected_lifecycle_epoch=1,
|
|
137
|
+
)
|
|
138
|
+
assert ok is False
|
|
139
|
+
with s._get_conn() as conn:
|
|
140
|
+
row = conn.execute(
|
|
141
|
+
"SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id='t0'"
|
|
142
|
+
).fetchone()
|
|
143
|
+
assert row[0] is None # untouched
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_mark_canonical_row_tagged_is_idempotent_for_already_tagged(tmp_path: Path) -> None:
|
|
147
|
+
"""Calling mark_canonical_row_tagged a second time on an already-tagged row returns False (no-op)."""
|
|
148
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
149
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
150
|
+
_seed_canonical_row(s, conv_id="c", canonical_id="t0", sort_key=1000.0, tagged=True)
|
|
151
|
+
ok = s.mark_canonical_row_tagged(
|
|
152
|
+
canonical_turn_id="t0", conversation_id="c",
|
|
153
|
+
expected_lifecycle_epoch=1,
|
|
154
|
+
)
|
|
155
|
+
assert ok is False # already tagged, WHERE tagged_at IS NULL excludes it
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_mark_canonical_row_tagged_wrong_conversation_id_returns_false(tmp_path: Path) -> None:
|
|
159
|
+
"""Belt-and-suspenders: mismatched conversation_id doesn't accidentally mark another conversation's row."""
|
|
160
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
161
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c1")
|
|
162
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c2")
|
|
163
|
+
_seed_canonical_row(s, conv_id="c1", canonical_id="t0", sort_key=1000.0)
|
|
164
|
+
ok = s.mark_canonical_row_tagged(
|
|
165
|
+
canonical_turn_id="t0", conversation_id="c2", # wrong conv
|
|
166
|
+
expected_lifecycle_epoch=1,
|
|
167
|
+
)
|
|
168
|
+
assert ok is False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_iter_untagged_uses_partial_index(tmp_path: Path) -> None:
|
|
172
|
+
"""``iter_untagged_canonical_rows`` must drive the partial index
|
|
173
|
+
``idx_canonical_turns_conv_untagged`` and NOT fall back to a full scan.
|
|
174
|
+
|
|
175
|
+
The A19 implementation originally routed through the
|
|
176
|
+
``canonical_turns_ordinal`` VIEW so the row parser could read
|
|
177
|
+
``row["turn_number"]``. SQLite's planner cannot push the partial index
|
|
178
|
+
through the view's ``ROW_NUMBER()`` co-routine, producing a plan like
|
|
179
|
+
``CO-ROUTINE canonical_turns_ordinal + SCAN ct + USE TEMP B-TREE FOR
|
|
180
|
+
ORDER BY`` — O(all rows in conversation) regardless of
|
|
181
|
+
``tagged_at``. The tagger calls this in a tight loop; missing the index
|
|
182
|
+
is the regression this test locks in.
|
|
183
|
+
"""
|
|
184
|
+
s = SQLiteStore(tmp_path / "vc.db")
|
|
185
|
+
s.upsert_conversation(tenant_id="t", conversation_id="c")
|
|
186
|
+
with s._get_conn() as conn:
|
|
187
|
+
plan = list(conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
EXPLAIN QUERY PLAN
|
|
190
|
+
SELECT ct.canonical_turn_id, ct.conversation_id, ct.turn_group_number,
|
|
191
|
+
ct.sort_key, ct.turn_hash, ct.hash_version,
|
|
192
|
+
ct.normalized_user_text, ct.normalized_assistant_text,
|
|
193
|
+
ct.user_content, ct.assistant_content,
|
|
194
|
+
ct.user_raw_content, ct.assistant_raw_content,
|
|
195
|
+
ct.primary_tag, ct.tags_json, ct.session_date, ct.sender,
|
|
196
|
+
ct.fact_signals_json, ct.code_refs_json,
|
|
197
|
+
ct.tagged_at, ct.compacted_at,
|
|
198
|
+
ct.first_seen_at, ct.last_seen_at,
|
|
199
|
+
ct.source_batch_id, ct.created_at, ct.updated_at
|
|
200
|
+
FROM canonical_turns AS ct
|
|
201
|
+
JOIN conversations AS c
|
|
202
|
+
ON c.conversation_id = ct.conversation_id
|
|
203
|
+
WHERE ct.conversation_id = ?
|
|
204
|
+
AND ct.tagged_at IS NULL
|
|
205
|
+
AND c.lifecycle_epoch = ?
|
|
206
|
+
ORDER BY ct.sort_key ASC
|
|
207
|
+
LIMIT ?
|
|
208
|
+
""",
|
|
209
|
+
("c", 1, 10),
|
|
210
|
+
).fetchall())
|
|
211
|
+
detail = " | ".join(str(tuple(row)) for row in plan)
|
|
212
|
+
assert "idx_canonical_turns_conv_untagged" in detail, (
|
|
213
|
+
f"iter_untagged_canonical_rows is not using the partial index. "
|
|
214
|
+
f"Plan was: {detail}"
|
|
215
|
+
)
|
|
216
|
+
# Also guard against the view regression: a plan that mentions
|
|
217
|
+
# ``canonical_turns_ordinal`` or ``TEMP B-TREE FOR ORDER BY`` means we
|
|
218
|
+
# fell back to the view path and lost the index.
|
|
219
|
+
assert "canonical_turns_ordinal" not in detail, (
|
|
220
|
+
f"Query must not route through the view. Plan was: {detail}"
|
|
221
|
+
)
|
|
222
|
+
assert "TEMP B-TREE FOR ORDER BY" not in detail, (
|
|
223
|
+
f"Query must not fall back to a temp sort. Plan was: {detail}"
|
|
224
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Tests for epoch-guarded ``iter_untagged_canonical_rows`` +
|
|
2
|
+
``mark_canonical_row_tagged`` on the Postgres backend.
|
|
3
|
+
|
|
4
|
+
Skipped unless ``VC_TEST_POSTGRES_URL`` is set. Mirrors
|
|
5
|
+
``test_canonical_row_epoch_guards.py`` (SQLite) 1:1 so both backends stay in
|
|
6
|
+
lockstep on the SQL-level epoch fence. ``conversations.conversation_id`` is
|
|
7
|
+
``TEXT PRIMARY KEY`` (aligned with ``canonical_turns.conversation_id``), and
|
|
8
|
+
``canonical_turns.canonical_turn_id`` is ``UUID``, so test conversation IDs
|
|
9
|
+
use ``str(uuid.uuid4())`` and canonical IDs use ``uuid.uuid4()``.
|
|
10
|
+
``source_batch_id`` is also UUID, so the seed helper generates a fresh UUID
|
|
11
|
+
per row. psycopg3 adapts ``datetime`` directly so we pass
|
|
12
|
+
``datetime.now(timezone.utc)`` rather than ISO strings for the
|
|
13
|
+
``TIMESTAMPTZ`` lifecycle columns on ``canonical_turns`` — except that
|
|
14
|
+
``tagged_at`` / ``first_seen_at`` / ``last_seen_at`` / ``created_at`` /
|
|
15
|
+
``updated_at`` on ``canonical_turns`` are ``TEXT`` in the Postgres schema
|
|
16
|
+
(not TIMESTAMPTZ), so ISO strings are the right shape for those.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
PG_URL = os.environ.get("VC_TEST_POSTGRES_URL")
|
|
28
|
+
pytestmark = pytest.mark.skipif(not PG_URL, reason="VC_TEST_POSTGRES_URL not set")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _store():
|
|
32
|
+
from virtual_context.storage.postgres import PostgresStore # deferred
|
|
33
|
+
return PostgresStore(PG_URL)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fresh(conv_id: str | None = None):
|
|
37
|
+
"""Create a fresh conversation with a unique UUID and return (store, conv_id)."""
|
|
38
|
+
conv_id = conv_id or str(uuid.uuid4())
|
|
39
|
+
s = _store()
|
|
40
|
+
s.upsert_conversation(tenant_id="t", conversation_id=conv_id)
|
|
41
|
+
return s, conv_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _seed_canonical_row(
|
|
45
|
+
s,
|
|
46
|
+
*,
|
|
47
|
+
conv_id: str,
|
|
48
|
+
canonical_id: str,
|
|
49
|
+
sort_key: float,
|
|
50
|
+
tagged: bool = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
53
|
+
conn = s._get_conn()
|
|
54
|
+
conn.execute(
|
|
55
|
+
"""
|
|
56
|
+
INSERT INTO canonical_turns (
|
|
57
|
+
canonical_turn_id, conversation_id, turn_hash, hash_version,
|
|
58
|
+
normalized_user_text, normalized_assistant_text,
|
|
59
|
+
user_content, assistant_content,
|
|
60
|
+
sort_key, source_batch_id, first_seen_at, last_seen_at,
|
|
61
|
+
covered_ingestible_entries, tagged_at,
|
|
62
|
+
created_at, updated_at
|
|
63
|
+
) VALUES (%s, %s, %s, 1, 'u','a','u_raw','a_raw', %s, %s, %s, %s, 1, %s, %s, %s)
|
|
64
|
+
""",
|
|
65
|
+
(
|
|
66
|
+
canonical_id,
|
|
67
|
+
conv_id,
|
|
68
|
+
f"h_{canonical_id}",
|
|
69
|
+
sort_key,
|
|
70
|
+
uuid.uuid4(), # source_batch_id (UUID)
|
|
71
|
+
now_iso,
|
|
72
|
+
now_iso,
|
|
73
|
+
now_iso if tagged else None,
|
|
74
|
+
now_iso,
|
|
75
|
+
now_iso,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_iter_untagged_returns_untagged_in_sort_key_order_pg():
|
|
81
|
+
s, cid = _fresh()
|
|
82
|
+
ids = [str(uuid.uuid4()) for _ in range(3)]
|
|
83
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=ids[0], sort_key=3000.0)
|
|
84
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=ids[1], sort_key=1000.0)
|
|
85
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=ids[2], sort_key=2000.0)
|
|
86
|
+
rows = s.iter_untagged_canonical_rows(
|
|
87
|
+
conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
|
|
88
|
+
)
|
|
89
|
+
ids_in_order = [r.canonical_turn_id for r in rows]
|
|
90
|
+
assert ids_in_order == [ids[1], ids[2], ids[0]] # sorted by sort_key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_iter_untagged_respects_batch_size_pg():
|
|
94
|
+
s, cid = _fresh()
|
|
95
|
+
for i in range(5):
|
|
96
|
+
_seed_canonical_row(
|
|
97
|
+
s, conv_id=cid, canonical_id=str(uuid.uuid4()),
|
|
98
|
+
sort_key=float(i * 1000),
|
|
99
|
+
)
|
|
100
|
+
rows = s.iter_untagged_canonical_rows(
|
|
101
|
+
conversation_id=cid, expected_lifecycle_epoch=1, batch_size=2,
|
|
102
|
+
)
|
|
103
|
+
assert len(rows) == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_iter_untagged_excludes_tagged_rows_pg():
|
|
107
|
+
s, cid = _fresh()
|
|
108
|
+
t_tagged = str(uuid.uuid4())
|
|
109
|
+
t_pending = str(uuid.uuid4())
|
|
110
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=t_tagged, sort_key=1000.0, tagged=True)
|
|
111
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=t_pending, sort_key=2000.0, tagged=False)
|
|
112
|
+
rows = s.iter_untagged_canonical_rows(
|
|
113
|
+
conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
|
|
114
|
+
)
|
|
115
|
+
assert [r.canonical_turn_id for r in rows] == [t_pending]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_iter_untagged_returns_empty_on_epoch_mismatch_pg():
|
|
119
|
+
"""SQL-level epoch guard. Stale-epoch caller sees no rows even if rows exist."""
|
|
120
|
+
s, cid = _fresh()
|
|
121
|
+
old_id = str(uuid.uuid4())
|
|
122
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=old_id, sort_key=1000.0)
|
|
123
|
+
s.mark_conversation_deleted(cid)
|
|
124
|
+
s.increment_lifecycle_epoch_on_resurrect(cid) # epoch=2
|
|
125
|
+
# Seed a new-lifecycle row.
|
|
126
|
+
new_id = str(uuid.uuid4())
|
|
127
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=new_id, sort_key=2000.0)
|
|
128
|
+
# Stale caller: epoch=1.
|
|
129
|
+
rows = s.iter_untagged_canonical_rows(
|
|
130
|
+
conversation_id=cid, expected_lifecycle_epoch=1, batch_size=10,
|
|
131
|
+
)
|
|
132
|
+
assert rows == [] # sees nothing
|
|
133
|
+
# Current caller: epoch=2 — sees both (old from old lifecycle not purged, plus new).
|
|
134
|
+
rows = s.iter_untagged_canonical_rows(
|
|
135
|
+
conversation_id=cid, expected_lifecycle_epoch=2, batch_size=10,
|
|
136
|
+
)
|
|
137
|
+
assert len(rows) == 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_mark_canonical_row_tagged_sets_timestamp_pg():
|
|
141
|
+
s, cid = _fresh()
|
|
142
|
+
t_pending = str(uuid.uuid4())
|
|
143
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=t_pending, sort_key=1000.0)
|
|
144
|
+
ok = s.mark_canonical_row_tagged(
|
|
145
|
+
canonical_turn_id=t_pending, conversation_id=cid,
|
|
146
|
+
expected_lifecycle_epoch=1,
|
|
147
|
+
)
|
|
148
|
+
assert ok is True
|
|
149
|
+
conn = s._get_conn()
|
|
150
|
+
row = conn.execute(
|
|
151
|
+
"SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id = %s",
|
|
152
|
+
(t_pending,),
|
|
153
|
+
).fetchone()
|
|
154
|
+
assert row["tagged_at"] is not None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_mark_canonical_row_tagged_rejects_on_epoch_mismatch_pg():
|
|
158
|
+
"""Proves SQL-level epoch guard on the mark boundary."""
|
|
159
|
+
s, cid = _fresh()
|
|
160
|
+
t0 = str(uuid.uuid4())
|
|
161
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=t0, sort_key=1000.0)
|
|
162
|
+
s.mark_conversation_deleted(cid)
|
|
163
|
+
s.increment_lifecycle_epoch_on_resurrect(cid) # epoch=2
|
|
164
|
+
# Stale caller tries to mark a row that still exists.
|
|
165
|
+
ok = s.mark_canonical_row_tagged(
|
|
166
|
+
canonical_turn_id=t0, conversation_id=cid,
|
|
167
|
+
expected_lifecycle_epoch=1,
|
|
168
|
+
)
|
|
169
|
+
assert ok is False
|
|
170
|
+
conn = s._get_conn()
|
|
171
|
+
row = conn.execute(
|
|
172
|
+
"SELECT tagged_at FROM canonical_turns WHERE canonical_turn_id = %s",
|
|
173
|
+
(t0,),
|
|
174
|
+
).fetchone()
|
|
175
|
+
assert row["tagged_at"] is None # untouched
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_mark_canonical_row_tagged_is_idempotent_for_already_tagged_pg():
|
|
179
|
+
"""Calling mark_canonical_row_tagged a second time on an already-tagged row returns False (no-op)."""
|
|
180
|
+
s, cid = _fresh()
|
|
181
|
+
t0 = str(uuid.uuid4())
|
|
182
|
+
_seed_canonical_row(s, conv_id=cid, canonical_id=t0, sort_key=1000.0, tagged=True)
|
|
183
|
+
ok = s.mark_canonical_row_tagged(
|
|
184
|
+
canonical_turn_id=t0, conversation_id=cid,
|
|
185
|
+
expected_lifecycle_epoch=1,
|
|
186
|
+
)
|
|
187
|
+
assert ok is False # already tagged, WHERE tagged_at IS NULL excludes it
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_mark_canonical_row_tagged_wrong_conversation_id_returns_false_pg():
|
|
191
|
+
"""Belt-and-suspenders: mismatched conversation_id doesn't accidentally mark another conversation's row."""
|
|
192
|
+
s1, c1 = _fresh()
|
|
193
|
+
_, c2 = _fresh()
|
|
194
|
+
t0 = str(uuid.uuid4())
|
|
195
|
+
_seed_canonical_row(s1, conv_id=c1, canonical_id=t0, sort_key=1000.0)
|
|
196
|
+
ok = s1.mark_canonical_row_tagged(
|
|
197
|
+
canonical_turn_id=t0, conversation_id=c2, # wrong conv
|
|
198
|
+
expected_lifecycle_epoch=1,
|
|
199
|
+
)
|
|
200
|
+
assert ok is False
|