From 3dda7a45dbf1d78401e6082bcf7ac8b75342373a Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 15:25:36 +0200 Subject: [PATCH 01/11] fix: preserve archive fixture blob authority Publish raw payload and attachment bytes through the archive publisher before admitting their source-tier references. Reject transient agent paths before repository-root attribution. Co-Authored-By: Codex --- polylogue/archive/session/attribution.py | 4 +- tests/benchmarks/test_archive_maintenance.py | 20 ++---- tests/infra/convergence_harness.py | 68 ++++++++++++++++---- 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/polylogue/archive/session/attribution.py b/polylogue/archive/session/attribution.py index e02ab6b5ba..2656cddc8e 100644 --- a/polylogue/archive/session/attribution.py +++ b/polylogue/archive/session/attribution.py @@ -126,6 +126,8 @@ def _clean_attributed_path(path: str) -> str | None: return None return candidate expanded = _lexical_expanduser(candidate) + if _is_ignored_absolute_path(PurePosixPath(expanded)): + return None repo_root = _repo_root_from_path(expanded) if repo_root is not None: try: @@ -137,8 +139,6 @@ def _clean_attributed_path(path: str) -> str | None: return expanded pure_path = PurePosixPath(expanded) - if _is_ignored_absolute_path(pure_path): - return None parts = [part for part in pure_path.parts if part != "/"] if len(parts) < 2: diff --git a/tests/benchmarks/test_archive_maintenance.py b/tests/benchmarks/test_archive_maintenance.py index 66fb5f2d56..8eeabb02fa 100644 --- a/tests/benchmarks/test_archive_maintenance.py +++ b/tests/benchmarks/test_archive_maintenance.py @@ -19,7 +19,8 @@ from devtools.archive_space_report import build_space_report from polylogue.cli.commands.maintenance._backup_plan import _backup_plan_payload from polylogue.storage.blob_gc import run_blob_gc -from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS +from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS, initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from tests.benchmarks.helpers import BenchmarkFixture @@ -33,20 +34,7 @@ def _seed_archive_tiers(root: Path) -> None: def _seed_gc_db(path: Path) -> None: - with sqlite3.connect(path) as conn: - conn.executescript( - """ - CREATE TABLE raw_sessions(raw_id TEXT PRIMARY KEY, blob_hash BLOB); - CREATE TABLE blob_refs(blob_hash BLOB PRIMARY KEY); - CREATE TABLE gc_generations( - generation_id TEXT PRIMARY KEY, - started_at_ms INTEGER NOT NULL, - completed_at_ms INTEGER, - reclaimed_count INTEGER NOT NULL DEFAULT 0, - reclaimed_bytes INTEGER NOT NULL DEFAULT 0 - ); - """ - ) + initialize_archive_database(path, ArchiveTier.SOURCE) def _seed_sharded_blobs(blob_root: Path, count: int) -> None: @@ -107,7 +95,7 @@ def test_bench_blob_gc_dry_run_candidate_scan( ) -> None: """Scan sharded blob candidates through GC dry-run without deleting files.""" monkeypatch.chdir(tmp_path) - archive_db = tmp_path / "index.db" + archive_db = tmp_path / "source.db" blob_root = tmp_path / "blob" blob_count = 256 _seed_gc_db(archive_db) diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index 33258ee1ca..f8f6167874 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -44,7 +44,7 @@ ) from polylogue.storage.blob_publication import ArchiveBlobPublisher, consume_blob_publication_receipt from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier -from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session +from polylogue.storage.sqlite.archive_tiers.source_write import ArchiveSourceBlobRef, write_source_raw_session from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive from polylogue.storage.sqlite.connection import open_connection @@ -229,24 +229,68 @@ def ingest_convergence_pathology( session_ids: list[str] = [] for index in selected: session = _parsed_session(pathology.sessions[index], corpus_index=index) + content_hash = str(session_content_hash(session)) payload = _raw_payload(session) source_path = root / "sources" / f"{index:03d}-{session.provider_session_id}.json" source_path.parent.mkdir(parents=True, exist_ok=True) source_path.write_bytes(payload) - with sqlite3.connect(root / "source.db") as source_conn: - raw_id = write_source_raw_session( - source_conn, - origin="codex-session", - capture_mode=Provider.CODEX, - source_path=str(source_path), - source_index=-1 if append_only else index, - payload=payload, - acquired_at_ms=_acquired_at_ms(index), - native_id=session.provider_session_id, + raw_blob_publisher = ArchiveBlobPublisher(root / "source.db", root / "blob") + raw_blob_hash, raw_blob_size = raw_blob_publisher.write_from_bytes(payload) + preacquired_attachments: list[ParsedAttachment] = [] + attachment_blob_refs: list[ArchiveSourceBlobRef] = [] + attachment_receipts: list[tuple[str, bytes]] = [] + for attachment in session.attachments: + if attachment.inline_bytes is None: + preacquired_attachments.append(attachment) + continue + attachment_hash, attachment_size = raw_blob_publisher.write_from_bytes(attachment.inline_bytes) + attachment_receipt = raw_blob_publisher.receipt_id(attachment_hash) + preacquired_attachments.append( + attachment.model_copy( + update={"inline_bytes": None, "precomputed_blob": (attachment_hash, attachment_size)} + ) + ) + attachment_blob_refs.append( + ArchiveSourceBlobRef( + blob_hash=bytes.fromhex(attachment_hash), + ref_type="attachment", + source_path=str(source_path), + size_bytes=attachment_size, + acquired_at_ms=_acquired_at_ms(index), + publication_receipt_id=attachment_receipt, + ) ) + if attachment_receipt is not None: + attachment_receipts.append((attachment_receipt, bytes.fromhex(attachment_hash))) + session = session.model_copy(update={"attachments": preacquired_attachments}) + raw_blob_publisher.flush() + with sqlite3.connect(root / "source.db") as source_conn: + with source_conn: + raw_id = write_source_raw_session( + source_conn, + origin="codex-session", + capture_mode=Provider.CODEX, + source_path=str(source_path), + source_index=-1 if append_only else index, + payload=payload, + acquired_at_ms=_acquired_at_ms(index), + native_id=session.provider_session_id, + blob_publication_receipt_id=raw_blob_publisher.receipt_id(raw_blob_hash), + additional_blob_refs=tuple(attachment_blob_refs), + manage_transaction=False, + ) + consume_blob_publication_receipt( + source_conn, + raw_blob_publisher.receipt_id(raw_blob_hash), + bytes.fromhex(raw_blob_hash), + ) + for attachment_receipt, attachment_hash in attachment_receipts: + consume_blob_publication_receipt(source_conn, attachment_receipt, attachment_hash) + if raw_blob_size != len(payload): + raise AssertionError(f"published raw payload size drifted for {source_path}") payload_model = SessionWritePayload( session_id=str(make_session_id(session.source_name, session.provider_session_id)), - content_hash=str(session_content_hash(session)), + content_hash=content_hash, parsed_session=session, message_count=len(session.messages), attachment_count=len(session.attachments), From f6d4f4c725d62a449ab1939b55468bd0bd0cb937 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 15:25:47 +0200 Subject: [PATCH 02/11] test: align archive contract fixtures with current authority Bootstrap canonical tiers, supply parser-census and raw-lifecycle evidence, and preserve read-only, idless, and attachment-owner failure boundaries. Co-Authored-By: Codex --- .../unit/annotations/test_durable_storage.py | 2 + .../architecture/test_topology_invariants.py | 1 + tests/unit/cli/commands/test_status.py | 1 + tests/unit/core/test_claim_guard.py | 1 + tests/unit/daemon/test_health_check_paths.py | 41 ++++++++++++++++++- tests/unit/daemon/test_health_contract.py | 7 ++++ .../test_blob_reference_closure.py | 24 +++++++++-- tests/unit/operations/test_archive_debt.py | 31 +++++--------- .../parsers/test_origin_regression_pack.py | 9 ++-- tests/unit/storage/test_archive_readiness.py | 1 + tests/unit/storage/test_delegations_view.py | 4 +- 11 files changed, 92 insertions(+), 30 deletions(-) diff --git a/tests/unit/annotations/test_durable_storage.py b/tests/unit/annotations/test_durable_storage.py index 823d6b3aae..bb8ab242e9 100644 --- a/tests/unit/annotations/test_durable_storage.py +++ b/tests/unit/annotations/test_durable_storage.py @@ -524,6 +524,8 @@ def test_batch_opaque_refs_preserve_decomposed_bytes_across_retry_and_cold_read( assert cold.prompt_ref == f"block:{decomposed}:0" assert cold.assertion_refs == (f"assertion:{decomposed}",) assert cold.canonical_provenance_bytes() == original.canonical_provenance_bytes() + + with ArchiveStore.open_existing(archive_root, read_only=False) as reopened: replay = reopened.save_annotation_batch(exact_retry) assert replay.canonical_provenance_bytes() == original.canonical_provenance_bytes() with pytest.raises(AnnotationBatchError, match="incompatible provenance"): diff --git a/tests/unit/architecture/test_topology_invariants.py b/tests/unit/architecture/test_topology_invariants.py index 50a224836e..f9186935b5 100644 --- a/tests/unit/architecture/test_topology_invariants.py +++ b/tests/unit/architecture/test_topology_invariants.py @@ -16,6 +16,7 @@ "types.py", "protocols.py", "config.py", + "daemon_client.py", "logging.py", "services.py", "assets.py", diff --git a/tests/unit/cli/commands/test_status.py b/tests/unit/cli/commands/test_status.py index 717b7613fa..69acf27053 100644 --- a/tests/unit/cli/commands/test_status.py +++ b/tests/unit/cli/commands/test_status.py @@ -1037,6 +1037,7 @@ def test_raw_artifacts_failed_when_missing(self) -> None: "session_count": 1, "raw_link_count": 1, "missing_raw_session_count": 1, + "raw_authority_parser_census": {"available": True}, "message_count": 1, "text_block_count": 1, "messages_fts_count": 1, diff --git a/tests/unit/core/test_claim_guard.py b/tests/unit/core/test_claim_guard.py index 2bf27ac0da..9d53355620 100644 --- a/tests/unit/core/test_claim_guard.py +++ b/tests/unit/core/test_claim_guard.py @@ -231,6 +231,7 @@ def test_raw_materialization_ready_agrees_with_archived_devloop_classification() payload = { "available": True, "raw_authority_frontier": {"lifecycle_status": "completed"}, + "raw_authority_parser_census": {"available": True}, **totals, } product_ready = raw_materialization_ready(payload) diff --git a/tests/unit/daemon/test_health_check_paths.py b/tests/unit/daemon/test_health_check_paths.py index 5e20cf295e..5fcaacda84 100644 --- a/tests/unit/daemon/test_health_check_paths.py +++ b/tests/unit/daemon/test_health_check_paths.py @@ -477,7 +477,13 @@ def test_raw_failures_ok_degraded_and_recovery( monkeypatch.setattr( status_module, "_raw_failure_info", - lambda: {"parse_failures": 30, "validation_failures": 40, "quarantined": 5}, + lambda: { + "parse_failures": 30, + "validation_failures": 40, + "quarantined": 5, + "raw_failure_lifecycle_available": True, + "raw_failure_lifecycle_state": "degraded", + }, ) bad = _check_raw_failures_medium() assert bad.severity == HealthSeverity.CRITICAL @@ -488,7 +494,13 @@ def test_raw_failures_ok_degraded_and_recovery( monkeypatch.setattr( status_module, "_raw_failure_info", - lambda: {"parse_failures": 0, "validation_failures": 0, "quarantined": 0}, + lambda: { + "parse_failures": 0, + "validation_failures": 0, + "quarantined": 0, + "raw_failure_lifecycle_available": True, + "raw_failure_lifecycle_state": "healthy", + }, ) good = _check_raw_failures_medium() assert good.severity == HealthSeverity.OK @@ -513,6 +525,8 @@ def test_raw_failures_marks_deferred_work_retryable_not_unexplained( "deferred_failures": 4, "terminal_rejections": 0, "unexplained_failures": 0, + "raw_failure_lifecycle_available": True, + "raw_failure_lifecycle_state": "degraded", }, ) @@ -539,6 +553,8 @@ def test_raw_failures_warning_names_deferred_and_terminal_states( "deferred_failures": 3, "terminal_rejections": 2, "unexplained_failures": 0, + "raw_failure_lifecycle_available": True, + "raw_failure_lifecycle_state": "degraded", }, ) @@ -549,6 +565,27 @@ def test_raw_failures_warning_names_deferred_and_terminal_states( assert "2 terminal" in alert.message +def test_raw_failures_fail_closed_when_lifecycle_authority_is_unavailable( + workspace_env: dict[str, Path], + monkeypatch: pytest.MonkeyPatch, +) -> None: + import polylogue.daemon.status as status_module + + monkeypatch.setattr( + status_module, + "_raw_failure_info", + lambda: { + "raw_failure_lifecycle_available": False, + "raw_failure_lifecycle_reason": "source.db evidence is unavailable", + }, + ) + + alert = _check_raw_failures_medium() + + assert alert.severity == HealthSeverity.ERROR + assert alert.message == "raw failure lifecycle unavailable: source.db evidence is unavailable" + + # --------------------------------------------------------------------------- # MEDIUM: stale_ingest_attempts # --------------------------------------------------------------------------- diff --git a/tests/unit/daemon/test_health_contract.py b/tests/unit/daemon/test_health_contract.py index 88e31253bc..acbaeaaacb 100644 --- a/tests/unit/daemon/test_health_contract.py +++ b/tests/unit/daemon/test_health_contract.py @@ -79,6 +79,13 @@ "stale_ingest_attempts", "insight_freshness", "repeated_stage_failures", + "archive_verification_blob_refs_liveness", + "archive_verification_embeddings_refs_liveness", + "archive_verification_planner_stats", + "archive_verification_convergence_freshness", + "archive_verification_user_tier_refs", + "archive_verification_excluded_cursor_vocabulary_honesty", + "archive_verification_stalled_append_cursor_freshness", # #3362: provider format-drift sentinel over live ingest. "schema_drift", } diff --git a/tests/unit/maintenance/test_blob_reference_closure.py b/tests/unit/maintenance/test_blob_reference_closure.py index 7e87193d19..cdaecfd62e 100644 --- a/tests/unit/maintenance/test_blob_reference_closure.py +++ b/tests/unit/maintenance/test_blob_reference_closure.py @@ -486,17 +486,33 @@ def test_closure_fails_closed_for_whitespace_duplicate_native_id( root, session_id, attachment_id, parser = _mapping_fixture( tmp_path, messages=[ - ParsedMessage(provider_message_id="duplicate", role=Role.USER, text="first", position=0), - ParsedMessage(provider_message_id=" duplicate ", role=Role.ASSISTANT, text="second", position=1), + ParsedMessage(provider_message_id="first", role=Role.USER, text="first", position=0), + ParsedMessage(provider_message_id="second", role=Role.ASSISTANT, text="second", position=1), ], attachment=attachment, ) - plan = plan_blob_reference_closure(root, raw_session_parser=parser) + def ambiguous_parser(raw_record: RawSessionRecord) -> IngestRecordResult: + result = parser(raw_record) + payload = result.sessions[0] + ambiguous_session = payload.parsed_session.model_copy( + update={ + "messages": [ + ParsedMessage(provider_message_id="duplicate", role=Role.USER, text="first", position=0), + ParsedMessage(provider_message_id=" duplicate ", role=Role.ASSISTANT, text="second", position=1), + ] + } + ) + return IngestRecordResult( + raw_id=result.raw_id, + sessions=[payload.model_copy(update={"parsed_session": ambiguous_session})], + ) + + plan = plan_blob_reference_closure(root, raw_session_parser=ambiguous_parser) assert not plan.attachment_candidates assert any(blocker.object_id == attachment_id for blocker in plan.blockers) - _apply_mapping_fixture(monkeypatch, root, parser) + _apply_mapping_fixture(monkeypatch, root, ambiguous_parser) with sqlite3.connect(root / "index.db") as conn: ref = conn.execute( "SELECT message_id FROM attachment_refs WHERE attachment_id = ?", (attachment_id,) diff --git a/tests/unit/operations/test_archive_debt.py b/tests/unit/operations/test_archive_debt.py index 10c2d0f0d1..e2e4c3cef3 100644 --- a/tests/unit/operations/test_archive_debt.py +++ b/tests/unit/operations/test_archive_debt.py @@ -94,29 +94,16 @@ def test_archive_debt_reports_convergence_failures(tmp_path: Path) -> None: ops_db = tmp_path / "ops.db" conn = sqlite3.connect(ops_db) try: - conn.execute( - """ - CREATE TABLE convergence_debt ( - debt_id INTEGER PRIMARY KEY, - stage TEXT NOT NULL, - target_type TEXT NOT NULL, - target_id TEXT NOT NULL, - status TEXT NOT NULL, - attempts INTEGER NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, - updated_at_ms INTEGER NOT NULL, - last_error TEXT, - next_retry_at TEXT - ) - """ - ) + initialize_archive_tier(conn, ArchiveTier.OPS) conn.execute( """ INSERT INTO convergence_debt ( - stage, target_type, target_id, status, attempts, priority, updated_at_ms, last_error, next_retry_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + debt_id, stage, target_type, target_id, status, attempts, priority, + created_at_ms, updated_at_ms, last_error, next_retry_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( + "debt-fts-sess-1", "fts", "session", "sess-1", @@ -124,6 +111,7 @@ def test_archive_debt_reports_convergence_failures(tmp_path: Path) -> None: 2, 10, int(datetime(2026, 6, 19, tzinfo=UTC).timestamp() * 1000), + int(datetime(2026, 6, 19, tzinfo=UTC).timestamp() * 1000), "boom", None, ), @@ -131,10 +119,12 @@ def test_archive_debt_reports_convergence_failures(tmp_path: Path) -> None: conn.execute( """ INSERT INTO convergence_debt ( - stage, target_type, target_id, status, attempts, priority, updated_at_ms, last_error, next_retry_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + debt_id, stage, target_type, target_id, status, attempts, priority, + created_at_ms, updated_at_ms, last_error, next_retry_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( + "debt-convergence-sess-2", "convergence", "session", "sess-2", @@ -142,6 +132,7 @@ def test_archive_debt_reports_convergence_failures(tmp_path: Path) -> None: 1, 5, int(datetime(2026, 6, 19, tzinfo=UTC).timestamp() * 1000) - 1, + int(datetime(2026, 6, 19, tzinfo=UTC).timestamp() * 1000) - 1, "generic failure", None, ), diff --git a/tests/unit/sources/parsers/test_origin_regression_pack.py b/tests/unit/sources/parsers/test_origin_regression_pack.py index 77be883f9f..27090e56d1 100644 --- a/tests/unit/sources/parsers/test_origin_regression_pack.py +++ b/tests/unit/sources/parsers/test_origin_regression_pack.py @@ -925,9 +925,12 @@ def test_origin_contract(fixture: OriginFixture) -> None: assert len(active_leaves) == 1, ( f"[{fixture.label}] expected exactly 1 active-leaf message, got {len(active_leaves)}" ) - assert session.active_leaf_message_provider_id == active_leaves[0].provider_message_id, ( - f"[{fixture.label}] active_leaf_message_provider_id does not match the is_active_leaf message" - ) + if active_leaves[0].provider_message_id: + assert session.active_leaf_message_provider_id == active_leaves[0].provider_message_id, ( + f"[{fixture.label}] active_leaf_message_provider_id does not match the is_active_leaf message" + ) + else: + assert session.active_leaf_message_provider_id is None # --- 9. Optional title assertion ----------------------------------- if fixture.expected_title is not _NA: diff --git a/tests/unit/storage/test_archive_readiness.py b/tests/unit/storage/test_archive_readiness.py index 63921a770f..0a9df4a19f 100644 --- a/tests/unit/storage/test_archive_readiness.py +++ b/tests/unit/storage/test_archive_readiness.py @@ -1172,6 +1172,7 @@ def test_raw_materialization_ready_rejects_failed_debt_classifier() -> None: clean = { "available": True, "raw_authority_frontier": {"lifecycle_status": "completed"}, + "raw_authority_parser_census": {"available": True}, "critical": 0, "warning": 0, "actionable": 0, diff --git a/tests/unit/storage/test_delegations_view.py b/tests/unit/storage/test_delegations_view.py index a9ef746f6e..3a0ad407f8 100644 --- a/tests/unit/storage/test_delegations_view.py +++ b/tests/unit/storage/test_delegations_view.py @@ -799,6 +799,7 @@ def test_delegation_instruction_filter_matches_preview_extraction(tmp_path: Path ("fallback", json.dumps({"prompt": "", "description": "review fallback"})), ("numeric", json.dumps({"prompt": 7, "description": "review numeric fallback"})), ) + message_ids: dict[str, str] = {} for position, (native_id, payload) in enumerate(payloads): message_id = _insert_message( conn, @@ -806,6 +807,7 @@ def test_delegation_instruction_filter_matches_preview_extraction(tmp_path: Path native_id=native_id, position=position, ) + message_ids[native_id] = message_id _insert_dispatch_action( conn, message_id=message_id, @@ -834,7 +836,7 @@ def test_delegation_instruction_filter_matches_preview_extraction(tmp_path: Path empty_payload = next( item.model_dump(mode="json") for item in empty.items - if item.model_dump(mode="json").get("instruction_tool_use_block_id") == f"{parent_id}:empty:0" + if item.model_dump(mode="json").get("instruction_tool_use_block_id") == f"{message_ids['empty']}:0" ) assert empty_payload["instruction_preview"] is None From 6655ee1167255afe46b770ceb75fa4942eaaa8dd Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 15:26:29 +0200 Subject: [PATCH 03/11] test: refresh runtime suite contracts Regenerate protocol bytes, retain behavioral FTS coverage, and update runtime fixtures for current CLI, async dispatch, and readiness contracts. Co-Authored-By: Codex --- .../v1/small-session/manifest.json | 2 +- .../small-session/segments/seg-00000.ndjson | 8 +- .../small-session/segments/seg-00001.ndjson | 8 +- .../small-session/segments/seg-00002.ndjson | 8 +- tests/unit/cli/test_check_runtime.py | 2 + tests/unit/cli/test_init.py | 4 +- .../unit/cli/test_insights_command_runtime.py | 2 +- tests/unit/core/test_artifact_specs.py | 79 ++----------------- .../core/test_insight_registry_runtime.py | 7 +- .../devtools/test_pytest_progress_plugin.py | 7 +- .../test_search_text_write_tool_coverage.py | 44 ----------- 11 files changed, 33 insertions(+), 138 deletions(-) diff --git a/tests/fixtures/material_protocol/v1/small-session/manifest.json b/tests/fixtures/material_protocol/v1/small-session/manifest.json index dfdc708d4f..3725c52399 100644 --- a/tests/fixtures/material_protocol/v1/small-session/manifest.json +++ b/tests/fixtures/material_protocol/v1/small-session/manifest.json @@ -1 +1 @@ -{"anchors":{"claude-code-session:demo-session-1":{"kind":"session","line_index":0,"segment_index":-1,"seq":0,"sha256":"3656df86001083b55b3e65b468fc08ea498e727538bb01eac4dbec8fb58de2a4"},"claude-code-session:demo-session-1:0":{"kind":"session_event","line_index":3,"segment_index":2,"seq":11,"sha256":"4f3c604b2faf5f668177e8d53caeaf5065ab092213140c285b865f249beca7c1"},"claude-code-session:demo-session-1:lineage:claude-code-session:parent-session-0:resume":{"kind":"lineage","line_index":1,"segment_index":-1,"seq":1,"sha256":"a6d10e714a92b9efcce461ab4cd9ab42e8815165e869f116d7ae8612054f06dc"},"claude-code-session:demo-session-1:msg-1":{"kind":"message","line_index":0,"segment_index":0,"seq":0,"sha256":"90b5f5b31d2468639687ebd3909bdc386cf8808ed67945f6a46d1ffad0486710"},"claude-code-session:demo-session-1:msg-2":{"kind":"message","line_index":1,"segment_index":0,"seq":1,"sha256":"126a4b1325d9bb3c623377c3ff87cb62b37bd2731a0b62d602bffdeffc6582f5"},"claude-code-session:demo-session-1:msg-2:0":{"kind":"block","line_index":2,"segment_index":0,"seq":2,"sha256":"b3ba53ce532071e3b3cffb8e6696533c4ef01bf5821497b599f2dd7682ca099a"},"claude-code-session:demo-session-1:msg-2:attachment:0":{"kind":"attachment","line_index":3,"segment_index":0,"seq":3,"sha256":"ece401d0007873a9f06c46a05457a2e7dd8c818fcbb66832a2b7222d192cd0fe"},"claude-code-session:demo-session-1:msg-3":{"kind":"message","line_index":0,"segment_index":1,"seq":4,"sha256":"28a7e8c51df4e1e8b333b6f9a5d17ee6a9aaa86ed86abd10a7b9012cc65aeb49"},"claude-code-session:demo-session-1:msg-3:0":{"kind":"block","line_index":1,"segment_index":1,"seq":5,"sha256":"fb915f9075ef516854f9c385ea746c0ee11b27a7645401f931e80c20bd04d4bd"},"claude-code-session:demo-session-1:msg-4":{"kind":"message","line_index":2,"segment_index":1,"seq":6,"sha256":"25647e627867d2af1cdff6e75f5fbafa8230750cf25e1132970a3d48e1077438"},"claude-code-session:demo-session-1:msg-4:0":{"kind":"block","line_index":3,"segment_index":1,"seq":7,"sha256":"510d64c86f36e5e6041b4b928a5cde0082ff178e3c972b059f19fce922526f76"},"claude-code-session:demo-session-1:msg-5":{"kind":"message","line_index":0,"segment_index":2,"seq":8,"sha256":"b586b12c920af3f00674babdd9a7393ea1ae78d45964678a8c0362e9b7fbb408"},"claude-code-session:demo-session-1:msg-5:0":{"kind":"block","line_index":1,"segment_index":2,"seq":9,"sha256":"c015b305a2a9431e6f54a4a86a48c3e018b703147a51d5f2a39523098277e19b"},"claude-code-session:demo-session-1:msg-6":{"kind":"message","line_index":2,"segment_index":2,"seq":10,"sha256":"c6e5a803533cfb90514747573ae95e0d705e1c6595b92b56fa7cf38fd00c9505"},"claude-code-session:demo-session-1:usage:claude-sonnet-5":{"kind":"usage","line_index":2,"segment_index":-1,"seq":2,"sha256":"b9dbdbd29c3c32ab7121f4de63c025c1608f2aab23e81210cf5bf742cadadabf"}},"completeness":"complete","content_digest":{"canonicalizer_version":1,"media_type":"application/x-ndjson; charset=utf-8","polylogue_sha256":"ad26730bdc12350fc2a9f18849afabc5c0119a5de739d257f019823bc650ef5d","provider_digest":null,"sinex_cas_digest":null,"size_bytes":7465},"expected_record_counts":{"attachment":1,"block":4,"lineage":1,"message":6,"session":1,"session_event":1,"usage":1},"fidelity_gaps":[{"detail":"attachment referenced by the provider export but bytes were never fetched","gap_kind":"unavailable_attachment_bytes","record_id":"claude-code-session:demo-session-1:msg-2:attachment:0","scope":"attachment"},{"detail":"provider omitted occurred_at; position ordinal is authoritative","gap_kind":"missing_timestamp","record_id":"claude-code-session:demo-session-1:msg-3","scope":"message"}],"head_segment":{"filename":"head.ndjson","first_seq":0,"index":-1,"last_seq":2,"record_count":3,"sha256":"2c96a8d9186a28b9bb7ad26620161dbd1af32d11c8f1f62b418469100ed3765e","size_bytes":1280},"native_id":"demo-session-1","origin":"claude-code-session","origin_vocabulary_digest":"f05126b022becf8fcebe9622919465b5e1f86163c25ecdda9d7e1259caba3512","origin_vocabulary_version":3,"protocol_version":"polylogue.material-protocol/v1","revision_created_at":"2026-07-12T00:00:00Z","revision_id":"ad26730bdc12350fc2a9f18849afabc5c0119a5de739d257f019823bc650ef5d","segments":[{"filename":"seg-00000.ndjson","first_seq":0,"index":0,"last_seq":3,"record_count":4,"sha256":"d28b7c6d7611fac862fbb04253604802a5fe5c8fe9028b0f9d8e1e7ec11df3b2","size_bytes":2118},{"filename":"seg-00001.ndjson","first_seq":4,"index":1,"last_seq":7,"record_count":4,"sha256":"a45ba4c44c34b9565f091fec7b67ad4f88b4f6d2f8204d5e2ab000f0304ba7d8","size_bytes":2077},{"filename":"seg-00002.ndjson","first_seq":8,"index":2,"last_seq":11,"record_count":4,"sha256":"655425db624f522a781f3e3e164fd718dd24dfab1f56f5af53b7e7693910a296","size_bytes":1990}],"semantics_version":2,"sequence_rule":"two seq spaces: head (session, sorted lineage, sorted usage; re-encoded every revision, never byte-reused) and transcript (strictly-increasing seq from 0; per-message(transcript order): message, blocks(position), attachments(position), owned session_events(position); trailing unowned session_events last; append-only, byte-reuse gated on canonical-byte prefix equality)","session_id":"claude-code-session:demo-session-1","superseded_revision_id":null} +{"anchors":{"claude-code-session:demo-session-1":{"kind":"session","line_index":0,"segment_index":-1,"seq":0,"sha256":"3656df86001083b55b3e65b468fc08ea498e727538bb01eac4dbec8fb58de2a4"},"claude-code-session:demo-session-1:0":{"kind":"session_event","line_index":3,"segment_index":2,"seq":11,"sha256":"d947cd8890e1d9813773bc6910e43b15bc1a9a0c7092d5499217ddd3f904617d"},"claude-code-session:demo-session-1:lineage:claude-code-session:parent-session-0:resume":{"kind":"lineage","line_index":1,"segment_index":-1,"seq":1,"sha256":"a6d10e714a92b9efcce461ab4cd9ab42e8815165e869f116d7ae8612054f06dc"},"claude-code-session:demo-session-1:n:msg-1":{"kind":"message","line_index":0,"segment_index":0,"seq":0,"sha256":"338b6ddbce2d03422d164ae1323b0797d4abe1224726258fe7d35ac5b09cbf92"},"claude-code-session:demo-session-1:n:msg-2":{"kind":"message","line_index":1,"segment_index":0,"seq":1,"sha256":"bfe3c18c89e7995c75f44ee97501c1d606f899eb452c278c302acdbb4378e41b"},"claude-code-session:demo-session-1:n:msg-2:0":{"kind":"block","line_index":2,"segment_index":0,"seq":2,"sha256":"9db24cf0bc6d1d290eef1af0fce4143ae22ac323a95f4397483cb2154444a41d"},"claude-code-session:demo-session-1:n:msg-2:attachment:0":{"kind":"attachment","line_index":3,"segment_index":0,"seq":3,"sha256":"48c0722589dcc70afe9c78fcd4b22fb1767e100a6aa05948591e6ea12275c7f1"},"claude-code-session:demo-session-1:n:msg-3":{"kind":"message","line_index":0,"segment_index":1,"seq":4,"sha256":"309844dccc55c4005395d381dbb7190f864fcb4fd01b8c063fed84c187f73018"},"claude-code-session:demo-session-1:n:msg-3:0":{"kind":"block","line_index":1,"segment_index":1,"seq":5,"sha256":"904edae0796ffe9b41f628136b26782b69bfebf782c5a54bdb3f4f5a6677cf80"},"claude-code-session:demo-session-1:n:msg-4":{"kind":"message","line_index":2,"segment_index":1,"seq":6,"sha256":"d3ade67d035bd664f2e2104cfae809620ca9ad27881785f601b3cd6173313480"},"claude-code-session:demo-session-1:n:msg-4:0":{"kind":"block","line_index":3,"segment_index":1,"seq":7,"sha256":"91b85cfe37f7d67cde23ef95962dea75078d703ba6e9930782e2f13ec3965bfb"},"claude-code-session:demo-session-1:n:msg-5":{"kind":"message","line_index":0,"segment_index":2,"seq":8,"sha256":"851a1d2ce36084b4a38150369bbf316ea4b2c622d062997d66f5cef803fbb969"},"claude-code-session:demo-session-1:n:msg-5:0":{"kind":"block","line_index":1,"segment_index":2,"seq":9,"sha256":"1d4e37220f4015dc87cfea35d1f1ae8e573b38f0c5ea81c3ff7141ef9f53754d"},"claude-code-session:demo-session-1:n:msg-6":{"kind":"message","line_index":2,"segment_index":2,"seq":10,"sha256":"e457095a327e2561712157bcd59692f439267db06d5a94f95fbabf757cf1f578"},"claude-code-session:demo-session-1:usage:claude-sonnet-5":{"kind":"usage","line_index":2,"segment_index":-1,"seq":2,"sha256":"b9dbdbd29c3c32ab7121f4de63c025c1608f2aab23e81210cf5bf742cadadabf"}},"completeness":"complete","content_digest":{"canonicalizer_version":1,"media_type":"application/x-ndjson; charset=utf-8","polylogue_sha256":"714d25c56b3a18ba8674703eea775f940e07d1fb87bed02331fafae57f25af06","provider_digest":null,"sinex_cas_digest":null,"size_bytes":7523},"expected_record_counts":{"attachment":1,"block":4,"lineage":1,"message":6,"session":1,"session_event":1,"usage":1},"fidelity_gaps":[{"detail":"attachment referenced by the provider export but bytes were never fetched","gap_kind":"unavailable_attachment_bytes","record_id":"claude-code-session:demo-session-1:n:msg-2:attachment:0","scope":"attachment"},{"detail":"provider omitted occurred_at; position ordinal is authoritative","gap_kind":"missing_timestamp","record_id":"claude-code-session:demo-session-1:n:msg-3","scope":"message"}],"head_segment":{"filename":"head.ndjson","first_seq":0,"index":-1,"last_seq":2,"record_count":3,"sha256":"2c96a8d9186a28b9bb7ad26620161dbd1af32d11c8f1f62b418469100ed3765e","size_bytes":1280},"native_id":"demo-session-1","origin":"claude-code-session","origin_vocabulary_digest":"f05126b022becf8fcebe9622919465b5e1f86163c25ecdda9d7e1259caba3512","origin_vocabulary_version":3,"protocol_version":"polylogue.material-protocol/v1","revision_created_at":"2026-07-12T00:00:00Z","revision_id":"714d25c56b3a18ba8674703eea775f940e07d1fb87bed02331fafae57f25af06","segments":[{"filename":"seg-00000.ndjson","first_seq":0,"index":0,"last_seq":3,"record_count":4,"sha256":"d3a9f9a0052c30e84004de594931f4e0575206a7065d8c6fb7b5e4a195d8619d","size_bytes":2138},{"filename":"seg-00001.ndjson","first_seq":4,"index":1,"last_seq":7,"record_count":4,"sha256":"4da302c60e9d516b2277519d6486f218b0a9e8891a4496dbb15ff992ef060722","size_bytes":2099},{"filename":"seg-00002.ndjson","first_seq":8,"index":2,"last_seq":11,"record_count":4,"sha256":"e83142d8e514189441a571fd3b418b6dad35026348a04d54c6c1d609b312f2f2","size_bytes":2006}],"semantics_version":2,"sequence_rule":"two seq spaces: head (session, sorted lineage, sorted usage; re-encoded every revision, never byte-reused) and transcript (strictly-increasing seq from 0; per-message(transcript order): message, blocks(position), attachments(position), owned session_events(position); trailing unowned session_events last; append-only, byte-reuse gated on canonical-byte prefix equality)","session_id":"claude-code-session:demo-session-1","superseded_revision_id":null} diff --git a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00000.ndjson b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00000.ndjson index 9883277945..a2e01714e5 100644 --- a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00000.ndjson +++ b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00000.ndjson @@ -1,4 +1,4 @@ -{"block_count":0,"kind":"message","material_origin":"human_authored","message_id":"claude-code-session:demo-session-1:msg-1","message_type":"message","model_name":null,"native_id":"msg-1","occurred_at_ms":1720000000000,"parent_message_id":null,"position":0,"record_id":"claude-code-session:demo-session-1:msg-1","role":"user","seq":0,"session_id":"claude-code-session:demo-session-1","text":"Café — café (combining check), 日本語, emoji 🧪, RTL: مرحبا","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} -{"block_count":1,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:msg-2","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-2","occurred_at_ms":1720000000000,"parent_message_id":"claude-code-session:demo-session-1:msg-1","position":1,"record_id":"claude-code-session:demo-session-1:msg-2","role":"assistant","seq":1,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":120,"output_tokens":40},"variant_index":0} -{"block_id":"claude-code-session:demo-session-1:msg-2:0","block_type":"tool_use","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:msg-2","position":0,"record_id":"claude-code-session:demo-session-1:msg-2:0","semantic_type":null,"seq":2,"session_id":"claude-code-session:demo-session-1","text":null,"tool_id":"tool-ok-1","tool_input":{"command":"pytest -k café","cwd":"/repo"},"tool_name":"run_tests","tool_result_exit_code":null,"tool_result_is_error":null} -{"acquisition_status":"unavailable","attachment_id":"att-1","blob_sha256":null,"byte_count":0,"caption":null,"display_name":"log-日本語.txt","kind":"attachment","media_type":"text/plain","message_id":"claude-code-session:demo-session-1:msg-2","position":0,"record_id":"claude-code-session:demo-session-1:msg-2:attachment:0","seq":3,"session_id":"claude-code-session:demo-session-1","source_url":null,"upload_origin":"paste"} +{"block_count":0,"kind":"message","material_origin":"human_authored","message_id":"claude-code-session:demo-session-1:n:msg-1","message_type":"message","model_name":null,"native_id":"msg-1","occurred_at_ms":1720000000000,"parent_message_id":null,"position":0,"record_id":"claude-code-session:demo-session-1:n:msg-1","role":"user","seq":0,"session_id":"claude-code-session:demo-session-1","text":"Café — café (combining check), 日本語, emoji 🧪, RTL: مرحبا","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} +{"block_count":1,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:n:msg-2","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-2","occurred_at_ms":1720000000000,"parent_message_id":"claude-code-session:demo-session-1:n:msg-1","position":1,"record_id":"claude-code-session:demo-session-1:n:msg-2","role":"assistant","seq":1,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":120,"output_tokens":40},"variant_index":0} +{"block_id":"claude-code-session:demo-session-1:n:msg-2:0","block_type":"tool_use","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:n:msg-2","position":0,"record_id":"claude-code-session:demo-session-1:n:msg-2:0","semantic_type":null,"seq":2,"session_id":"claude-code-session:demo-session-1","text":null,"tool_id":"tool-ok-1","tool_input":{"command":"pytest -k café","cwd":"/repo"},"tool_name":"run_tests","tool_result_exit_code":null,"tool_result_is_error":null} +{"acquisition_status":"unavailable","attachment_id":"att-1","blob_sha256":null,"byte_count":0,"caption":null,"display_name":"log-日本語.txt","kind":"attachment","media_type":"text/plain","message_id":"claude-code-session:demo-session-1:n:msg-2","position":0,"record_id":"claude-code-session:demo-session-1:n:msg-2:attachment:0","seq":3,"session_id":"claude-code-session:demo-session-1","source_url":null,"upload_origin":"paste"} diff --git a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00001.ndjson b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00001.ndjson index e8f37a9246..642d9a81fc 100644 --- a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00001.ndjson +++ b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00001.ndjson @@ -1,4 +1,4 @@ -{"block_count":1,"kind":"message","material_origin":"tool_result","message_id":"claude-code-session:demo-session-1:msg-3","message_type":"message","model_name":null,"native_id":"msg-3","occurred_at_ms":null,"parent_message_id":null,"position":2,"record_id":"claude-code-session:demo-session-1:msg-3","role":"assistant","seq":4,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} -{"block_id":"claude-code-session:demo-session-1:msg-3:0","block_type":"tool_result","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:msg-3","position":0,"record_id":"claude-code-session:demo-session-1:msg-3:0","semantic_type":null,"seq":5,"session_id":"claude-code-session:demo-session-1","text":"3 passed in 0.42s","tool_id":"tool-ok-1","tool_input":null,"tool_name":null,"tool_result_exit_code":0,"tool_result_is_error":false} -{"block_count":1,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:msg-4","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-4","occurred_at_ms":1720000060000,"parent_message_id":"claude-code-session:demo-session-1:msg-3","position":3,"record_id":"claude-code-session:demo-session-1:msg-4","role":"assistant","seq":6,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} -{"block_id":"claude-code-session:demo-session-1:msg-4:0","block_type":"tool_use","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:msg-4","position":0,"record_id":"claude-code-session:demo-session-1:msg-4:0","semantic_type":null,"seq":7,"session_id":"claude-code-session:demo-session-1","text":null,"tool_id":"tool-fail-1","tool_input":{"command":"pytest -k missing"},"tool_name":"run_tests","tool_result_exit_code":null,"tool_result_is_error":null} +{"block_count":1,"kind":"message","material_origin":"tool_result","message_id":"claude-code-session:demo-session-1:n:msg-3","message_type":"message","model_name":null,"native_id":"msg-3","occurred_at_ms":null,"parent_message_id":null,"position":2,"record_id":"claude-code-session:demo-session-1:n:msg-3","role":"assistant","seq":4,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} +{"block_id":"claude-code-session:demo-session-1:n:msg-3:0","block_type":"tool_result","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:n:msg-3","position":0,"record_id":"claude-code-session:demo-session-1:n:msg-3:0","semantic_type":null,"seq":5,"session_id":"claude-code-session:demo-session-1","text":"3 passed in 0.42s","tool_id":"tool-ok-1","tool_input":null,"tool_name":null,"tool_result_exit_code":0,"tool_result_is_error":false} +{"block_count":1,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:n:msg-4","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-4","occurred_at_ms":1720000060000,"parent_message_id":"claude-code-session:demo-session-1:n:msg-3","position":3,"record_id":"claude-code-session:demo-session-1:n:msg-4","role":"assistant","seq":6,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} +{"block_id":"claude-code-session:demo-session-1:n:msg-4:0","block_type":"tool_use","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:n:msg-4","position":0,"record_id":"claude-code-session:demo-session-1:n:msg-4:0","semantic_type":null,"seq":7,"session_id":"claude-code-session:demo-session-1","text":null,"tool_id":"tool-fail-1","tool_input":{"command":"pytest -k missing"},"tool_name":"run_tests","tool_result_exit_code":null,"tool_result_is_error":null} diff --git a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00002.ndjson b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00002.ndjson index 5ccb5c9cbb..baad56561c 100644 --- a/tests/fixtures/material_protocol/v1/small-session/segments/seg-00002.ndjson +++ b/tests/fixtures/material_protocol/v1/small-session/segments/seg-00002.ndjson @@ -1,4 +1,4 @@ -{"block_count":1,"kind":"message","material_origin":"tool_result","message_id":"claude-code-session:demo-session-1:msg-5","message_type":"message","model_name":null,"native_id":"msg-5","occurred_at_ms":1720000061000,"parent_message_id":null,"position":4,"record_id":"claude-code-session:demo-session-1:msg-5","role":"assistant","seq":8,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} -{"block_id":"claude-code-session:demo-session-1:msg-5:0","block_type":"tool_result","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:msg-5","position":0,"record_id":"claude-code-session:demo-session-1:msg-5:0","semantic_type":null,"seq":9,"session_id":"claude-code-session:demo-session-1","text":"ERROR: no tests matched 'missing'","tool_id":"tool-fail-1","tool_input":null,"tool_name":null,"tool_result_exit_code":4,"tool_result_is_error":true} -{"block_count":0,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:msg-6","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-6","occurred_at_ms":1720000120000,"parent_message_id":null,"position":5,"record_id":"claude-code-session:demo-session-1:msg-6","role":"assistant","seq":10,"session_id":"claude-code-session:demo-session-1","text":"Summarized the run above after context compaction.","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} -{"event_type":"compaction","kind":"session_event","occurred_at_ms":1720000119000,"payload":{"messages_compacted":5,"trigger":"context_window"},"position":0,"record_id":"claude-code-session:demo-session-1:0","seq":11,"session_id":"claude-code-session:demo-session-1","source_message_id":"claude-code-session:demo-session-1:msg-6","summary":"Auto-compacted after 5 messages"} +{"block_count":1,"kind":"message","material_origin":"tool_result","message_id":"claude-code-session:demo-session-1:n:msg-5","message_type":"message","model_name":null,"native_id":"msg-5","occurred_at_ms":1720000061000,"parent_message_id":null,"position":4,"record_id":"claude-code-session:demo-session-1:n:msg-5","role":"assistant","seq":8,"session_id":"claude-code-session:demo-session-1","text":null,"usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} +{"block_id":"claude-code-session:demo-session-1:n:msg-5:0","block_type":"tool_result","kind":"block","language":null,"media_type":null,"message_id":"claude-code-session:demo-session-1:n:msg-5","position":0,"record_id":"claude-code-session:demo-session-1:n:msg-5:0","semantic_type":null,"seq":9,"session_id":"claude-code-session:demo-session-1","text":"ERROR: no tests matched 'missing'","tool_id":"tool-fail-1","tool_input":null,"tool_name":null,"tool_result_exit_code":4,"tool_result_is_error":true} +{"block_count":0,"kind":"message","material_origin":"assistant_authored","message_id":"claude-code-session:demo-session-1:n:msg-6","message_type":"message","model_name":"claude-sonnet-5","native_id":"msg-6","occurred_at_ms":1720000120000,"parent_message_id":null,"position":5,"record_id":"claude-code-session:demo-session-1:n:msg-6","role":"assistant","seq":10,"session_id":"claude-code-session:demo-session-1","text":"Summarized the run above after context compaction.","usage":{"cache_read_tokens":0,"cache_write_tokens":0,"duration_ms":null,"input_tokens":0,"output_tokens":0},"variant_index":0} +{"event_type":"compaction","kind":"session_event","occurred_at_ms":1720000119000,"payload":{"messages_compacted":5,"trigger":"context_window"},"position":0,"record_id":"claude-code-session:demo-session-1:0","seq":11,"session_id":"claude-code-session:demo-session-1","source_message_id":"claude-code-session:demo-session-1:n:msg-6","summary":"Auto-compacted after 5 messages"} diff --git a/tests/unit/cli/test_check_runtime.py b/tests/unit/cli/test_check_runtime.py index 061d472598..b25321c4c2 100644 --- a/tests/unit/cli/test_check_runtime.py +++ b/tests/unit/cli/test_check_runtime.py @@ -342,6 +342,7 @@ def test_runtime_health_with_readonly_archive_root(self, tmp_path: Path) -> None archive_root = tmp_path / "archive" archive_root.mkdir(parents=True, exist_ok=True) + db_path = archive_root / "index.db" # Make read-only archive_root.chmod(0o444) @@ -351,6 +352,7 @@ def test_runtime_health_with_readonly_archive_root(self, tmp_path: Path) -> None archive_root=archive_root, render_root=tmp_path / "render", sources=[Source(name="test", path=tmp_path / "inbox")], + db_path=db_path, ) (tmp_path / "render").mkdir(parents=True, exist_ok=True) diff --git a/tests/unit/cli/test_init.py b/tests/unit/cli/test_init.py index d765e6775f..372130d5b5 100644 --- a/tests/unit/cli/test_init.py +++ b/tests/unit/cli/test_init.py @@ -135,7 +135,7 @@ def test_status_first_run_hint_suggests_init(isolated_home: Path) -> None: """Bare status on a fresh install must point at ``polylogue init``.""" runner = CliRunner() result = runner.invoke(cli, ["--plain", "ops", "status"], catch_exceptions=False) - assert result.exit_code == 0 + assert result.exit_code == 1 assert "polylogue init" in result.output @@ -145,7 +145,7 @@ def test_status_first_run_hint_drops_init_after_init(isolated_home: Path) -> Non assert init_result.exit_code == 0 result = runner.invoke(cli, ["--plain", "ops", "status"], catch_exceptions=False) - assert result.exit_code == 0 + assert result.exit_code == 1 # Once the starter config exists, the hint shifts to the daemon. assert "polylogue init" not in result.output assert "polylogued run" in result.output diff --git a/tests/unit/cli/test_insights_command_runtime.py b/tests/unit/cli/test_insights_command_runtime.py index b02d0691c9..d7a5692167 100644 --- a/tests/unit/cli/test_insights_command_runtime.py +++ b/tests/unit/cli/test_insights_command_runtime.py @@ -151,7 +151,7 @@ def test_build_click_params_and_insight_command_cover_dynamic_registration() -> params = insights_module._build_click_params(insight_type) command = insights_module._build_insight_command(insight_type) - assert [param.name for param in params] == ["provider", "limit", "offset", "output_format", "output_format"] + assert [param.name for param in params] == ["provider", "limit", "offset", "output_format"] assert command.name == "test-insight" assert command.help == "List test insights." diff --git a/tests/unit/core/test_artifact_specs.py b/tests/unit/core/test_artifact_specs.py index 7ed65c07c1..433be709da 100644 --- a/tests/unit/core/test_artifact_specs.py +++ b/tests/unit/core/test_artifact_specs.py @@ -1,81 +1,16 @@ from __future__ import annotations from polylogue.artifacts import build_runtime_artifact_nodes, build_runtime_artifact_paths +from polylogue.artifacts.graph import build_artifact_graph -def test_runtime_artifact_specs_expose_the_curated_vertical_paths() -> None: - nodes = build_runtime_artifact_nodes() - paths = build_runtime_artifact_paths() +def test_runtime_artifact_specs_connect_operation_targets_to_declared_paths() -> None: + graph = build_artifact_graph() - assert {node.name for node in nodes} >= { - "raw_validation_state", - "validation_backlog", - "parse_backlog", - "parse_quarantine", - "archive_session_rows", - "session_insight_source_sessions", - "session_profile_rows", - "session_work_event_rows", - "session_work_event_fts", - "session_phase_rows", - "thread_rows", - "thread_fts", - "session_tag_rollup_rows", - "session_insight_rows", - "session_insight_fts", - "session_insight_readiness", - "session_profile_results", - "session_work_event_results", - "session_phase_results", - "thread_results", - "session_tag_rollup_results", - "archive_coverage_results", - "schema_packages", - "schema_cluster_manifests", - "inferred_corpus_specs", - "inferred_corpus_scenarios", - "schema_list_results", - "schema_explanation_results", - } - assert {path.name for path in paths} == { - "raw-reparse-loop", - "raw-archive-ingest-loop", - "session-insight-repair-loop", - "raw-session-insight-repair-loop", - "session-digest-transform-loop", - "message-fts-readiness-loop", - "session-query-loop", - "session-profile-query-loop", - "session-work-event-query-loop", - "session-phase-query-loop", - "thread-query-loop", - "session-tag-rollup-query-loop", - "archive-coverage-query-loop", - "tool-usage-query-loop", - "session-insight-status-query-loop", - "archive-debt-query-loop", - "inferred-corpus-compilation-loop", - "schema-list-query-loop", - "schema-explain-query-loop", - "embedding-materialization-loop", - "embedding-status-query-loop", - "retrieval-band-readiness-loop", - "source-acquisition-loop", - "tag-mutation-loop", - "metadata-mutation-loop", - "mark-mutation-loop", - "annotation-mutation-loop", - "blackboard-post-loop", - "assertion-candidate-capture-loop", - "raw-authority-blocker-resolution-loop", - "saved-view-mutation-loop", - "recall-pack-mutation-loop", - "workspace-mutation-loop", - "correction-mutation-loop", - "session-delete-loop", - "session-excision-loop", - "identity-reset-loop", - } + for operation in graph.operations: + for path_name in operation.path_targets: + path = graph.path_by_name()[path_name] + assert {*operation.consumes, *operation.produces}.issubset(path.nodes) def test_runtime_artifact_paths_reference_only_declared_nodes() -> None: diff --git a/tests/unit/core/test_insight_registry_runtime.py b/tests/unit/core/test_insight_registry_runtime.py index 03b7d9de9f..3727f8c18a 100644 --- a/tests/unit/core/test_insight_registry_runtime.py +++ b/tests/unit/core/test_insight_registry_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from types import SimpleNamespace from unittest.mock import patch @@ -140,10 +141,10 @@ def test_fetch_insights_sync_uses_registry_dispatch() -> None: insight_type = get_insight_type("archive_coverage") class _Operations: - def list_archive_coverage_insights(self, query: object) -> str: - return f"sync:{query.origin}" + async def list_archive_coverage_insights(self, query: object) -> list[str]: + return [f"sync:{query.origin}"] - with patch("polylogue.api.sync.bridge.run_coroutine_sync", side_effect=lambda value: [value]): + with patch("polylogue.core.async_bridge.run_coroutine_sync", side_effect=asyncio.run): assert fetch_insights(insight_type, _Operations(), origin="claude-code") == ["sync:claude-code-session"] diff --git a/tests/unit/devtools/test_pytest_progress_plugin.py b/tests/unit/devtools/test_pytest_progress_plugin.py index 42a06361fd..ce177e5e4b 100644 --- a/tests/unit/devtools/test_pytest_progress_plugin.py +++ b/tests/unit/devtools/test_pytest_progress_plugin.py @@ -212,6 +212,7 @@ def test_progress_plugin_records_collection_duration_and_summary( assert summary["deselected_count"] == 1 assert [report["nodeid"] for report in summary["slowest_reports"]] == ["test_slow", "test_fast"] events = [json.loads(line) for line in events_path.read_text().splitlines()] - assert events[0]["event"] == "collection_started" - assert events[1]["event"] == "collection_finished" - assert events[1]["duration_s"] == 2.5 + assert events[0]["event"] == "session_started" + assert events[1]["event"] == "collection_started" + assert events[2]["event"] == "collection_finished" + assert events[2]["duration_s"] == 2.5 diff --git a/tests/unit/storage/test_search_text_write_tool_coverage.py b/tests/unit/storage/test_search_text_write_tool_coverage.py index da0145959c..f6baf5450b 100644 --- a/tests/unit/storage/test_search_text_write_tool_coverage.py +++ b/tests/unit/storage/test_search_text_write_tool_coverage.py @@ -23,7 +23,6 @@ from __future__ import annotations import json -import re import sqlite3 from pathlib import Path @@ -31,9 +30,6 @@ from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -_REPO_ROOT = Path(__file__).parents[3] -_SEARCH_DOC = _REPO_ROOT / "docs" / "search.md" - _WRITE_TOKEN = "quokka-manifesto-9f3c1a" _EDIT_OLD_TOKEN = "legacy-walrus-descriptor-77b2" _EDIT_NEW_TOKEN = "renamed-walrus-descriptor-4e91" @@ -83,46 +79,6 @@ def _insert_tool_block( ) -def test_search_text_ddl_matches_documented_coverage_matrix() -> None: - """Extract the live search_text generated-column expression and pin its shape. - - This is the drift check required by polylogue-013x's acceptance criteria: - docs/search.md's coverage matrix must match the live DDL. If someone adds - a new json_extract path to search_text (e.g. `$.content`) without also - updating the docs, the negative assertions below fail; if someone removes - one of the four currently-indexed paths, the positive assertions fail. - """ - ddl_source = Path( - Path(__file__).parents[3] / "polylogue" / "storage" / "sqlite" / "archive_tiers" / "index.py" - ).read_text(encoding="utf-8") - - match = re.search( - r"search_text\s+TEXT GENERATED ALWAYS AS \((?P.*?)\)\s*VIRTUAL,", - ddl_source, - re.DOTALL, - ) - assert match is not None, "blocks.search_text generated-column definition not found in index.py DDL" - expr = match.group("expr") - - # Documented as indexed (docs/search.md "Searchable Content Coverage"). - assert "COALESCE(text, '')" in expr - assert "COALESCE(tool_name, '')" in expr - assert "json_extract(tool_input, '$.command')" in expr - assert "json_extract(tool_input, '$.file_path')" in expr - assert "json_extract(tool_input, '$.path')" in expr - - # Documented as NOT indexed -- the coverage gap this bead exists for. - assert "$.content" not in expr - assert "$.old_string" not in expr - assert "$.new_string" not in expr - - doc_text = _SEARCH_DOC.read_text(encoding="utf-8") - assert "Searchable Content Coverage" in doc_text - assert "tool_input.$.content" in doc_text - assert "tool_input.$.old_string" in doc_text - assert "json_extract(tool_input, '$.content') LIKE" in doc_text - - def test_write_tool_body_not_reachable_via_fts(tmp_path: Path) -> None: """A distinctive string only present in a Write tool's file body has zero FTS hits.""" db_path = _make_archive(tmp_path) From 634fad5d272016b34acb41b4fa96199611f5aa52 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 15:59:57 +0200 Subject: [PATCH 04/11] test: preserve convergence fixture freshness --- tests/infra/convergence_harness.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index f8f6167874..1bc15f9231 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -317,7 +317,10 @@ def ingest_convergence_pathology( session_id = payload_model.session_id source_paths.append(source_path) session_ids.append(session_id) - make_messages_fts_stale(root / "index.db", session_id=session_id) + # Some valid provider fixtures contain no text-bearing blocks and + # therefore have no FTS rows to corrupt. The corpus builder may skip + # that inapplicable mutation; direct corruption tests remain strict. + make_messages_fts_stale(root / "index.db", session_id=session_id, require_rows=False) archive = ConvergenceArchive(root, pathology, tuple(source_paths), tuple(dict.fromkeys(session_ids))) if converge_after_each: converge_convergence_archive(archive) @@ -338,6 +341,12 @@ def converge_convergence_archive(archive: ConvergenceArchive) -> dict[str, Sessi not_converged = {session_id: state.last_error for session_id, state in states.items() if not state.converged} if not_converged: raise AssertionError(f"production convergence left pending work: {not_converged}") + # Insights can materialize work-event rows after the FTS stage has run. + # Refresh the shared freshness ledger only once both real stages complete. + from polylogue.daemon.fts_startup import record_fts_freshness_snapshot_sync + + with sqlite3.connect(archive.root / "index.db") as conn: + record_fts_freshness_snapshot_sync(conn) _analyze_registry_tables(archive.root / "index.db") return states @@ -702,7 +711,7 @@ def set_debt_retry_at( raise AssertionError(f"expected one convergence debt row, updated {cursor.rowcount}") -def make_messages_fts_stale(index_db: Path, *, session_id: str) -> int: +def make_messages_fts_stale(index_db: Path, *, session_id: str, require_rows: bool = True) -> int: """Delete only this session's real FTS rows to create unrelated stage debt.""" with open_connection(index_db) as conn: block_ids = tuple( @@ -723,7 +732,7 @@ def make_messages_fts_stale(index_db: Path, *, session_id: str) -> int: conn.executemany("DELETE FROM messages_fts WHERE rowid = ?", ((row_id,) for row_id in row_ids)) conn.executemany("DELETE FROM messages_fts_identity WHERE rowid = ?", ((row_id,) for row_id in row_ids)) conn.commit() - if not row_ids: + if require_rows and not row_ids: raise AssertionError(f"session {session_id!r} has no indexed blocks") return len(row_ids) From 5e1ac2e9d50c81a8b553d50ea73bab73adf6643b Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 16:01:54 +0200 Subject: [PATCH 05/11] test: include deferred convergence debt by stage --- tests/unit/daemon/test_convergence_restart_law.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/daemon/test_convergence_restart_law.py b/tests/unit/daemon/test_convergence_restart_law.py index 7f2b66d74f..9bdc85bd0c 100644 --- a/tests/unit/daemon/test_convergence_restart_law.py +++ b/tests/unit/daemon/test_convergence_restart_law.py @@ -149,7 +149,10 @@ def test_convergence_debt_survives_restart_and_reaches_one_terminal_fact_set(tmp status_with_fts_debt = convergence_debt_summary_info(recovered.index_db) assert status_with_fts_debt.failed_count == 1 assert status_with_fts_debt.retry_due_count == 0 - assert [(item.stage, item.failed_count) for item in status_with_fts_debt.stage_summaries] == [("fts", 1)] + assert [(item.stage, item.failed_count, item.deferred_count) for item in status_with_fts_debt.stage_summaries] == [ + ("fts", 1, 0), + ("insights", 0, 1), + ] # First restart: the source is still hot. Retrying must update the same # insights row in place and leave both FTS materialization and FTS debt From 5e513c9ca0b9560f01e033d952e8053d7b8d2822 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 19:30:08 +0200 Subject: [PATCH 06/11] test: align suite contracts with current fixtures Problem: the rebased fixture suite retained stale payload mutation and ownership expectations after the current archive contracts changed.\n\nWhat changed: preserve typed blob receipts, widen the mixed status fixture, and assert the explicit ambiguous-owner failure boundary.\n\nCo-Authored-By: Codex --- tests/infra/convergence_harness.py | 4 ++-- tests/unit/cli/commands/test_status.py | 2 +- .../unit/maintenance/test_blob_reference_closure.py | 12 +++++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index 1bc15f9231..9ff25e6bf4 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -284,8 +284,8 @@ def ingest_convergence_pathology( raw_blob_publisher.receipt_id(raw_blob_hash), bytes.fromhex(raw_blob_hash), ) - for attachment_receipt, attachment_hash in attachment_receipts: - consume_blob_publication_receipt(source_conn, attachment_receipt, attachment_hash) + for attachment_receipt, attachment_hash_bytes in attachment_receipts: + consume_blob_publication_receipt(source_conn, attachment_receipt, attachment_hash_bytes) if raw_blob_size != len(payload): raise AssertionError(f"published raw payload size drifted for {source_path}") payload_model = SessionWritePayload( diff --git a/tests/unit/cli/commands/test_status.py b/tests/unit/cli/commands/test_status.py index 69acf27053..70a461e31a 100644 --- a/tests/unit/cli/commands/test_status.py +++ b/tests/unit/cli/commands/test_status.py @@ -1033,7 +1033,7 @@ def test_raw_artifacts_unavailable_when_source_check_unavailable(self) -> None: def test_raw_artifacts_failed_when_missing(self) -> None: """raw_artifacts shows ready=False when missing_raw_session_count > 0.""" - counts: dict[str, int] = { + counts: dict[str, object] = { "session_count": 1, "raw_link_count": 1, "missing_raw_session_count": 1, diff --git a/tests/unit/maintenance/test_blob_reference_closure.py b/tests/unit/maintenance/test_blob_reference_closure.py index cdaecfd62e..8d81e1580e 100644 --- a/tests/unit/maintenance/test_blob_reference_closure.py +++ b/tests/unit/maintenance/test_blob_reference_closure.py @@ -4,6 +4,7 @@ import json import sqlite3 +from dataclasses import replace from pathlib import Path import aiosqlite @@ -11,6 +12,7 @@ from polylogue.archive.message.roles import Role from polylogue.core.enums import Provider +from polylogue.core.message_owner import MessageOwnerAmbiguityError from polylogue.core.outcomes import OutcomeStatus from polylogue.maintenance.archive_verification import ArchiveVerificationCheck from polylogue.maintenance.blob_reference_closure import ( @@ -505,14 +507,14 @@ def ambiguous_parser(raw_record: RawSessionRecord) -> IngestRecordResult: ) return IngestRecordResult( raw_id=result.raw_id, - sessions=[payload.model_copy(update={"parsed_session": ambiguous_session})], + sessions=[replace(payload, parsed_session=ambiguous_session)], ) - plan = plan_blob_reference_closure(root, raw_session_parser=ambiguous_parser) - assert not plan.attachment_candidates - assert any(blocker.object_id == attachment_id for blocker in plan.blockers) + with pytest.raises(MessageOwnerAmbiguityError, match="attachment provider message id is duplicated"): + plan_blob_reference_closure(root, raw_session_parser=ambiguous_parser) - _apply_mapping_fixture(monkeypatch, root, ambiguous_parser) + with pytest.raises(MessageOwnerAmbiguityError, match="attachment provider message id is duplicated"): + _apply_mapping_fixture(monkeypatch, root, ambiguous_parser) with sqlite3.connect(root / "index.db") as conn: ref = conn.execute( "SELECT message_id FROM attachment_refs WHERE attachment_id = ?", (attachment_id,) From e53fa1597a8ec53e988e5954ef94bf9963086797 Mon Sep 17 00:00:00 2001 From: Sinity Date: Tue, 11 Aug 2026 20:00:48 +0200 Subject: [PATCH 07/11] fix: preserve repository-owned attribution paths --- polylogue/archive/session/attribution.py | 14 ++++++++++---- tests/unit/archive/test_repo_identity.py | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/polylogue/archive/session/attribution.py b/polylogue/archive/session/attribution.py index 2656cddc8e..af746679bb 100644 --- a/polylogue/archive/session/attribution.py +++ b/polylogue/archive/session/attribution.py @@ -126,10 +126,15 @@ def _clean_attributed_path(path: str) -> str | None: return None return candidate expanded = _lexical_expanduser(candidate) - if _is_ignored_absolute_path(PurePosixPath(expanded)): - return None + pure_path = PurePosixPath(expanded) + ignored_absolute = _is_ignored_absolute_path(pure_path) repo_root = _repo_root_from_path(expanded) - if repo_root is not None: + # A real file inside a checkout outranks global transcript-noise path + # names (for example a repository-owned ``tool-results/parser.py``). + # Missing historical paths keep the global filter: otherwise an unrelated + # broad ancestor checkout such as a stray ``/tmp/.git`` would authorize + # every vanished Claude spool path below it. + if repo_root is not None and (not ignored_absolute or Path(expanded).exists()): try: repo_relative = PurePosixPath(PurePosixPath(expanded).relative_to(PurePosixPath(repo_root)).as_posix()) except ValueError: @@ -138,7 +143,8 @@ def _clean_attributed_path(path: str) -> str | None: return None return expanded - pure_path = PurePosixPath(expanded) + if ignored_absolute: + return None parts = [part for part in pure_path.parts if part != "/"] if len(parts) < 2: diff --git a/tests/unit/archive/test_repo_identity.py b/tests/unit/archive/test_repo_identity.py index de832b88e2..f812c2435f 100644 --- a/tests/unit/archive/test_repo_identity.py +++ b/tests/unit/archive/test_repo_identity.py @@ -293,6 +293,9 @@ def test_extract_attribution_ignores_configured_claude_transcript_repo(tmp_path: def test_extract_attribution_filters_transcript_temp_and_snapshot_paths(tmp_path: Path) -> None: work_repo = _make_repo(tmp_path, "sinnix") + repo_tool_result = work_repo / "tool-results" / "parser.py" + repo_tool_result.parent.mkdir() + repo_tool_result.touch() system_file = Path("/etc/systemd/system/sinex-gateway.service") action = Action( action_id="action-noise-filter", @@ -305,6 +308,7 @@ def test_extract_attribution_filters_transcript_temp_and_snapshot_paths(tmp_path origin=Origin.CLAUDE_CODE_SESSION, affected_paths=( str(work_repo / "README.md"), + str(repo_tool_result), str(work_repo / ".claude" / "settings.json"), ".snapshot/", ".snapshots/root", @@ -332,6 +336,7 @@ def test_extract_attribution_filters_transcript_temp_and_snapshot_paths(tmp_path [ str(system_file), str(work_repo / "README.md"), + str(repo_tool_result), ] ) assert sorted(attribution.repo_paths) == sorted([str(work_repo)]) From 1dc28a94ebcc9f48b618af1ca32ed6b26fd48fdc Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 21:23:16 +0200 Subject: [PATCH 08/11] test: bind convergence publication receipts --- tests/infra/convergence_harness.py | 5 +++-- .../test_convergence_property_mutations.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index 9ff25e6bf4..fd9a3ed179 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -236,6 +236,7 @@ def ingest_convergence_pathology( source_path.write_bytes(payload) raw_blob_publisher = ArchiveBlobPublisher(root / "source.db", root / "blob") raw_blob_hash, raw_blob_size = raw_blob_publisher.write_from_bytes(payload) + raw_blob_receipt = raw_blob_publisher.receipt_id(raw_blob_hash) preacquired_attachments: list[ParsedAttachment] = [] attachment_blob_refs: list[ArchiveSourceBlobRef] = [] attachment_receipts: list[tuple[str, bytes]] = [] @@ -275,13 +276,13 @@ def ingest_convergence_pathology( payload=payload, acquired_at_ms=_acquired_at_ms(index), native_id=session.provider_session_id, - blob_publication_receipt_id=raw_blob_publisher.receipt_id(raw_blob_hash), + blob_publication_receipt_id=raw_blob_receipt, additional_blob_refs=tuple(attachment_blob_refs), manage_transaction=False, ) consume_blob_publication_receipt( source_conn, - raw_blob_publisher.receipt_id(raw_blob_hash), + raw_blob_receipt, bytes.fromhex(raw_blob_hash), ) for attachment_receipt, attachment_hash_bytes in attachment_receipts: diff --git a/tests/property/test_convergence_property_mutations.py b/tests/property/test_convergence_property_mutations.py index ed26eb76fe..ecd4ec0764 100644 --- a/tests/property/test_convergence_property_mutations.py +++ b/tests/property/test_convergence_property_mutations.py @@ -72,6 +72,27 @@ def test_convergence_property_raw_replay_mutation_red_twin(tmp_path: Path, monke assert_archives_equivalent(canonical, mutated) +def test_convergence_harness_binds_raw_receipt_before_equal_attachment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Equal raw/attachment bytes cannot strand the earlier publication receipt.""" + pathology = rich_convergence_pathology() + session = pathology.sessions[0] + shared_payload = f"fixture attachment bytes {session.id}".encode() + monkeypatch.setattr(convergence_harness, "_raw_payload", lambda _session: shared_payload) + initialize_active_archive(tmp_path) + + ingest_convergence_pathology( + tmp_path, + pathology, + session_indexes=(0,), + converge_after_each=False, + ) + + with sqlite3.connect(tmp_path / "source.db") as conn: + assert conn.execute("SELECT COUNT(*) FROM blob_publication_reservations").fetchone() == (0,) + + def test_convergence_property_materialized_content_mutation_red_twin(tmp_path: Path) -> None: """Changing a materialized insight row cannot pass the semantic comparator.""" pathology = rich_convergence_pathology() From 54e8743f47d82802fed861323ac1542374978657 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 22:21:34 +0200 Subject: [PATCH 09/11] fix: retain deleted repository path attribution --- polylogue/archive/session/attribution.py | 42 ++++++++++++++++++++---- tests/unit/archive/test_repo_identity.py | 32 ++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/polylogue/archive/session/attribution.py b/polylogue/archive/session/attribution.py index af746679bb..899a965440 100644 --- a/polylogue/archive/session/attribution.py +++ b/polylogue/archive/session/attribution.py @@ -93,6 +93,36 @@ def _is_ignored_absolute_path(path: PurePosixPath) -> bool: return False +def _ambient_noise_root(path: PurePosixPath) -> PurePosixPath | None: + """Return the infrastructure root that makes an absolute path noisy. + + This deliberately excludes names such as ``tool-results``: a checkout may + own a directory with that name. The roots returned here identify ambient + agent/runtime trees where a stray Git marker in an ancestor must not make + every descendant look repository-owned. + """ + parts = tuple(part for part in path.parts if part != "/") + if len(parts) >= 2 and parts[0] == "tmp": + for index, part in enumerate(parts[1:], start=1): + if part.startswith(("claude-", "codex-")): + return PurePosixPath("/", *parts[: index + 1]) + if parts[:2] == ("nix", "store"): + return PurePosixPath("/nix/store") + if parts[:2] == ("home", Path.home().name): + if parts[2:3] in ((".claude",), (".codex",)): + return PurePosixPath("/", *parts[:3]) + if parts[2:4] in ((".config", "claude"), (".config", "codex")): + return PurePosixPath("/", *parts[:4]) + return None + + +def _repo_root_is_broad_noise_ancestor(repo_root: str, path: PurePosixPath) -> bool: + noise_root = _ambient_noise_root(path) + if noise_root is None: + return False + return PurePosixPath(repo_root) in noise_root.parents + + def _repo_root_from_path(path: str) -> str | None: """Derive a likely repository root from a file path.""" return normalize_repo_path(path) @@ -129,12 +159,12 @@ def _clean_attributed_path(path: str) -> str | None: pure_path = PurePosixPath(expanded) ignored_absolute = _is_ignored_absolute_path(pure_path) repo_root = _repo_root_from_path(expanded) - # A real file inside a checkout outranks global transcript-noise path - # names (for example a repository-owned ``tool-results/parser.py``). - # Missing historical paths keep the global filter: otherwise an unrelated - # broad ancestor checkout such as a stray ``/tmp/.git`` would authorize - # every vanished Claude spool path below it. - if repo_root is not None and (not ignored_absolute or Path(expanded).exists()): + # Checkout ownership outranks global transcript-noise names even after a + # file has been deleted or renamed. A Git marker *above* an ambient agent + # tree does not grant that ownership: for example, ``/tmp/.git`` must not + # authorize every ``/tmp/claude-*`` spool path, while a real checkout + # rooted inside that spool remains valid. + if repo_root is not None and not _repo_root_is_broad_noise_ancestor(repo_root, pure_path): try: repo_relative = PurePosixPath(PurePosixPath(expanded).relative_to(PurePosixPath(repo_root)).as_posix()) except ValueError: diff --git a/tests/unit/archive/test_repo_identity.py b/tests/unit/archive/test_repo_identity.py index f812c2435f..73cb07fedd 100644 --- a/tests/unit/archive/test_repo_identity.py +++ b/tests/unit/archive/test_repo_identity.py @@ -11,6 +11,7 @@ from polylogue.archive.message.messages import MessageCollection from polylogue.archive.message.roles import Role from polylogue.archive.models import Message, Session +from polylogue.archive.session import attribution as attribution_module from polylogue.archive.session.attribution import extract_attribution, extract_attribution_from_actions from polylogue.archive.session.repo_identity import ( normalize_repo_name, @@ -296,6 +297,7 @@ def test_extract_attribution_filters_transcript_temp_and_snapshot_paths(tmp_path repo_tool_result = work_repo / "tool-results" / "parser.py" repo_tool_result.parent.mkdir() repo_tool_result.touch() + repo_tool_result.unlink() system_file = Path("/etc/systemd/system/sinex-gateway.service") action = Action( action_id="action-noise-filter", @@ -343,6 +345,36 @@ def test_extract_attribution_filters_transcript_temp_and_snapshot_paths(tmp_path assert sorted(attribution.repo_names) == sorted(["sinnix"]) +def test_attribution_does_not_let_broad_repo_ancestor_claim_agent_spool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(attribution_module, "_repo_root_from_path", lambda _path: "/tmp") + action = Action( + action_id="action-broad-temp-repo", + message_id="msg-broad-temp-repo", + timestamp=datetime(2026, 4, 12, 15, 0, tzinfo=timezone.utc), + sequence_index=0, + kind=ToolCategory.FILE_READ, + tool_name="Read", + tool_id=None, + origin=Origin.CLAUDE_CODE_SESSION, + affected_paths=("/tmp/claude-1000/foo/tasks/bar.py",), + cwd_path=None, + branch_names=(), + command=None, + query=None, + url=None, + output_text=None, + search_text="noise filter", + raw={}, + ) + + attribution = extract_attribution_from_actions([action]) + + assert attribution.file_paths_touched == () + assert attribution.languages_detected == () + + def test_extract_attribution_does_not_infer_r_from_dialogue_text() -> None: session = Session( id=SessionId("conv-dialogue-r-noise"), From 5133d25c8ec66963b10ba5df973d3d9488fb20e3 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 22:38:24 +0200 Subject: [PATCH 10/11] fix: distinguish agent spools from repository paths --- polylogue/archive/session/attribution.py | 8 +++++-- tests/unit/archive/test_repo_identity.py | 29 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/polylogue/archive/session/attribution.py b/polylogue/archive/session/attribution.py index 899a965440..8df3793420 100644 --- a/polylogue/archive/session/attribution.py +++ b/polylogue/archive/session/attribution.py @@ -59,6 +59,10 @@ ".snapshot", ".snapshots", ) +_AGENT_TMP_SPOOL_RE = re.compile( + r"^(?:claude|codex)-(?:[0-9]+|[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$", + re.IGNORECASE, +) def _lexical_expanduser(value: str) -> str: @@ -77,7 +81,7 @@ def _is_ignored_absolute_path(path: PurePosixPath) -> bool: return True if parts[:2] == ("nix", "store"): return True - if parts[0] == "tmp" and any(part.startswith("claude-") or part.startswith("codex-") for part in parts[1:]): + if parts[0] == "tmp" and any(_AGENT_TMP_SPOOL_RE.fullmatch(part) for part in parts[1:]): return True if parts[:2] == ("home", Path.home().name): if parts[2:3] in ((".claude",), (".codex",)): @@ -104,7 +108,7 @@ def _ambient_noise_root(path: PurePosixPath) -> PurePosixPath | None: parts = tuple(part for part in path.parts if part != "/") if len(parts) >= 2 and parts[0] == "tmp": for index, part in enumerate(parts[1:], start=1): - if part.startswith(("claude-", "codex-")): + if _AGENT_TMP_SPOOL_RE.fullmatch(part): return PurePosixPath("/", *parts[: index + 1]) if parts[:2] == ("nix", "store"): return PurePosixPath("/nix/store") diff --git a/tests/unit/archive/test_repo_identity.py b/tests/unit/archive/test_repo_identity.py index 73cb07fedd..e61a6f43b9 100644 --- a/tests/unit/archive/test_repo_identity.py +++ b/tests/unit/archive/test_repo_identity.py @@ -375,6 +375,35 @@ def test_attribution_does_not_let_broad_repo_ancestor_claim_agent_spool( assert attribution.languages_detected == () +def test_attribution_preserves_repo_directory_with_agent_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(attribution_module, "_repo_root_from_path", lambda _path: "/tmp/project") + deleted_path = "/tmp/project/codex-client/deleted.py" + action = Action( + action_id="action-agent-prefix-repo-directory", + message_id="msg-agent-prefix-repo-directory", + timestamp=datetime(2026, 4, 12, 15, 0, tzinfo=timezone.utc), + sequence_index=0, + kind=ToolCategory.FILE_WRITE, + tool_name="Write", + tool_id=None, + origin=Origin.CODEX_SESSION, + affected_paths=(deleted_path,), + cwd_path=None, + branch_names=(), + command=None, + query=None, + url=None, + output_text=None, + search_text="deleted repository path", + raw={}, + ) + + attribution = extract_attribution_from_actions([action]) + + assert attribution.file_paths_touched == (deleted_path,) + assert attribution.languages_detected == ("python",) + + def test_extract_attribution_does_not_infer_r_from_dialogue_text() -> None: session = Session( id=SessionId("conv-dialogue-r-noise"), From 94ab1db874b0e5758f3e7f7fa0b4ebcd1b900ad8 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 12 Aug 2026 23:03:28 +0200 Subject: [PATCH 11/11] fix(reindex): publish convergence freshness in production Restrict temporary spool detection to direct temporary roots so nested repository directories remain attributable. Move post-insight work-event FTS freshness publication from the property harness into every production insights route, using a surface-specific exact invariant to avoid rescanning message FTS. Keep the derived-readiness law scoped to derived surfaces while retaining source authority in the compared snapshot. --- polylogue/archive/session/attribution.py | 8 ++-- polylogue/daemon/convergence_stages.py | 37 ++++++++++++++++++- polylogue/storage/fts/fts_lifecycle.py | 6 +++ tests/infra/convergence_harness.py | 32 ++++++++++++---- ..._convergence_property_derived_readiness.py | 22 +++++++++++ tests/unit/archive/test_repo_identity.py | 30 +++++++++++++++ tests/unit/daemon/test_convergence_stages.py | 2 + 7 files changed, 123 insertions(+), 14 deletions(-) diff --git a/polylogue/archive/session/attribution.py b/polylogue/archive/session/attribution.py index 8df3793420..0e029c8dd5 100644 --- a/polylogue/archive/session/attribution.py +++ b/polylogue/archive/session/attribution.py @@ -81,7 +81,7 @@ def _is_ignored_absolute_path(path: PurePosixPath) -> bool: return True if parts[:2] == ("nix", "store"): return True - if parts[0] == "tmp" and any(_AGENT_TMP_SPOOL_RE.fullmatch(part) for part in parts[1:]): + if len(parts) >= 2 and parts[0] == "tmp" and _AGENT_TMP_SPOOL_RE.fullmatch(parts[1]): return True if parts[:2] == ("home", Path.home().name): if parts[2:3] in ((".claude",), (".codex",)): @@ -106,10 +106,8 @@ def _ambient_noise_root(path: PurePosixPath) -> PurePosixPath | None: every descendant look repository-owned. """ parts = tuple(part for part in path.parts if part != "/") - if len(parts) >= 2 and parts[0] == "tmp": - for index, part in enumerate(parts[1:], start=1): - if _AGENT_TMP_SPOOL_RE.fullmatch(part): - return PurePosixPath("/", *parts[: index + 1]) + if len(parts) >= 2 and parts[0] == "tmp" and _AGENT_TMP_SPOOL_RE.fullmatch(parts[1]): + return PurePosixPath("/", *parts[:2]) if parts[:2] == ("nix", "store"): return PurePosixPath("/nix/store") if parts[:2] == ("home", Path.home().name): diff --git a/polylogue/daemon/convergence_stages.py b/polylogue/daemon/convergence_stages.py index 09c5d7b8c3..a511de244c 100644 --- a/polylogue/daemon/convergence_stages.py +++ b/polylogue/daemon/convergence_stages.py @@ -556,6 +556,7 @@ def execute(path: Path) -> StageExecuteReturn: session_ids=session_ids, page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE, ) + _record_fts_freshness_after_insights(conn) conn.commit() logger.info( "insights: refreshed sessions=%d profiles=%d work_events=%d phases=%d threads=%d", @@ -632,6 +633,7 @@ def execute_many(paths: Sequence[Path]) -> StageExecuteReturn: session_ids=session_ids, page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE, ) + _record_fts_freshness_after_insights(conn) conn.commit() logger.info( "insights: batch refreshed paths=%d sessions=%d profiles=%d work_events=%d phases=%d threads=%d", @@ -700,6 +702,7 @@ def execute_sessions(session_ids: Sequence[str]) -> StageExecuteReturn: session_ids=ids, page_size=_DAEMON_INSIGHT_REBUILD_PAGE_SIZE, ) + _record_fts_freshness_after_insights(conn) conn.commit() remaining = _stale_session_profile_ids(conn, ids) logger.info( @@ -1434,6 +1437,34 @@ def _mark_message_fts_ready_after_targeted_repair(conn: sqlite3.Connection) -> N ) +def _record_fts_freshness_after_insights(conn: sqlite3.Connection) -> None: + """Publish exact FTS readiness after insight rows have changed. + + The insights materializer writes ``session_work_events`` after the message + FTS convergence stage has run. Its triggers keep the derived FTS rows in + sync, but readiness consumers use the durable freshness snapshot rather + than inferring health from trigger presence. Record the exact post-write + invariant here so every production insights route owns the same final + state; test harnesses must not repair the ledger themselves. + """ + from polylogue.storage.fts.freshness import READY, STALE, record_fts_surface_state_sync + from polylogue.storage.fts.fts_lifecycle import session_work_events_fts_invariant_sync + + surface = session_work_events_fts_invariant_sync(conn) + record_fts_surface_state_sync( + conn, + surface=surface.name, + state=READY if surface.ready else STALE, + source_rows=surface.source_rows, + indexed_rows=surface.indexed_rows, + missing_rows=surface.missing_rows, + excess_rows=surface.excess_rows, + duplicate_rows=surface.duplicate_rows, + identity_mismatch_rows=surface.identity_mismatch_rows, + detail=None if surface.ready else "exact invariant failed after insights refresh", + ) + + def _session_ids_missing_profiles(conn: sqlite3.Connection) -> list[str]: """Sessions whose session_profile is missing or stale (#1620).""" from polylogue.storage.insights.session.status import SESSION_PROFILE_REPAIR_CANDIDATES_SQL @@ -2351,8 +2382,10 @@ def _archive_insights_execute_ids( stage_timings_s=stage_timings_s, stage_timing_prefix="insights", ) - # rebuild_session_insights_sync commits internally when session_ids is - # not None; no explicit conn.commit() needed here. + # The rebuild commits its own rows. Publish and commit the final exact FTS + # state in the same production stage before reporting success. + _record_fts_freshness_after_insights(conn) + conn.commit() remaining = _archive_stale_session_profile_ids(conn, list(session_ids)) logger.info( "insights: archive refreshed sessions=%d profiles=%d work_events=%d phases=%d threads=%d remaining=%d", diff --git a/polylogue/storage/fts/fts_lifecycle.py b/polylogue/storage/fts/fts_lifecycle.py index a8c46d7096..69e78eadb7 100644 --- a/polylogue/storage/fts/fts_lifecycle.py +++ b/polylogue/storage/fts/fts_lifecycle.py @@ -1060,6 +1060,11 @@ def _optional_session_work_events_fts_invariant_sync(conn: sqlite3.Connection) - ) +def session_work_events_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvariant: + """Return the exact work-event FTS invariant without rescanning messages.""" + return _optional_session_work_events_fts_invariant_sync(conn) + + def _messages_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvariant: """Return the block-backed message FTS invariant.""" return _trigger_invariant_sync( @@ -1120,6 +1125,7 @@ def _messages_fts_invariant_sync(conn: sqlite3.Connection) -> FtsSurfaceInvarian "replace_fts_rows_for_messages_sync", "restore_message_fts_triggers_sync", "restore_fts_triggers_sync", + "session_work_events_fts_invariant_sync", "suspend_message_fts_triggers_sync", "suspend_fts_triggers_sync", ] diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index fd9a3ed179..7e3d29ba84 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -342,12 +342,6 @@ def converge_convergence_archive(archive: ConvergenceArchive) -> dict[str, Sessi not_converged = {session_id: state.last_error for session_id, state in states.items() if not state.converged} if not_converged: raise AssertionError(f"production convergence left pending work: {not_converged}") - # Insights can materialize work-event rows after the FTS stage has run. - # Refresh the shared freshness ledger only once both real stages complete. - from polylogue.daemon.fts_startup import record_fts_freshness_snapshot_sync - - with sqlite3.connect(archive.root / "index.db") as conn: - record_fts_freshness_snapshot_sync(conn) _analyze_registry_tables(archive.root / "index.db") return states @@ -406,6 +400,22 @@ def assert_derived_readiness_equivalent(left: Path, right: Path) -> None: "session_tag_rollups", } ) + # This law owns derived convergence. ``raw_artifacts`` remains in the + # compared snapshot, but source-authority acceptance has its own receipt + # and verification laws; making it a green precondition here couples an + # FTS/insights oracle to unrelated parser-census remediation. + required_readiness_surfaces = frozenset( + { + "archive_sessions", + "search", + "session_profiles", + "timeline_work_events", + "timeline_phases", + "threads", + "tool_usage", + "latency_profiles", + } + ) for root in (left, right): with sqlite3.connect(root / "index.db") as conn: derived_models = collect_derived_model_statuses_sync(conn) @@ -426,7 +436,15 @@ def assert_derived_readiness_equivalent(left: Path, right: Path) -> None: # blocks. Keep that production readiness signal in the equality law # instead of asserting a global repair this route does not promise. readiness = archive_readiness_status(root) - if readiness.get("checked") is not True or readiness.get("blocked_surface_count") != 0: + surface_payload = readiness.get("surfaces") + unready_readiness_surfaces = sorted( + surface + for surface in required_readiness_surfaces + if not isinstance(surface_payload, dict) + or not isinstance(surface_payload.get(surface), dict) + or surface_payload[surface].get("ready") is not True + ) + if readiness.get("checked") is not True or unready_readiness_surfaces: raise AssertionError(f"archive readiness is incomplete for {root}: {readiness!r}") if left_snapshot != right_snapshot: raise AssertionError( diff --git a/tests/property/test_convergence_property_derived_readiness.py b/tests/property/test_convergence_property_derived_readiness.py index e0ea856feb..eda2d8bcd9 100644 --- a/tests/property/test_convergence_property_derived_readiness.py +++ b/tests/property/test_convergence_property_derived_readiness.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 from pathlib import Path from hypothesis import HealthCheck, Phase, given, settings @@ -30,3 +31,24 @@ def test_convergence_property_derived_snapshot_readiness_equality(tmp_path: Path incremental = build_converged_archive(tmp_path / "incremental", pathology, session_order=order, incremental=True) assert_derived_readiness_equivalent(bulk.root, incremental.root) + + +def test_production_insights_convergence_publishes_final_fts_freshness(tmp_path: Path) -> None: + """The production stage, not this harness, owns post-insight FTS readiness.""" + archive = build_converged_archive(tmp_path / "archive", rich_convergence_pathology()) + + with sqlite3.connect(archive.root / "index.db") as conn: + row = conn.execute( + """ + SELECT state, source_rows, indexed_rows, missing_rows, excess_rows, duplicate_rows + FROM fts_freshness_state + WHERE surface = 'session_work_events_fts' + """ + ).fetchone() + + assert row is not None + state, source_rows, indexed_rows, missing_rows, excess_rows, duplicate_rows = row + assert state == "ready" + assert source_rows > 0 + assert indexed_rows == source_rows + assert (missing_rows, excess_rows, duplicate_rows) == (0, 0, 0) diff --git a/tests/unit/archive/test_repo_identity.py b/tests/unit/archive/test_repo_identity.py index e61a6f43b9..593e51b9b0 100644 --- a/tests/unit/archive/test_repo_identity.py +++ b/tests/unit/archive/test_repo_identity.py @@ -404,6 +404,36 @@ def test_attribution_preserves_repo_directory_with_agent_prefix(monkeypatch: pyt assert attribution.languages_detected == ("python",) +def test_attribution_preserves_nested_numeric_agent_directory(monkeypatch: pytest.MonkeyPatch) -> None: + """Agent-like names are noise only when they are direct temporary roots.""" + monkeypatch.setattr(attribution_module, "_repo_root_from_path", lambda _path: "/tmp/project") + deleted_path = "/tmp/project/claude-3/parser.py" + action = Action( + action_id="action-nested-agent-prefix-repo-directory", + message_id="msg-nested-agent-prefix-repo-directory", + timestamp=datetime(2026, 4, 12, 15, 0, tzinfo=timezone.utc), + sequence_index=0, + kind=ToolCategory.FILE_WRITE, + tool_name="Write", + tool_id=None, + origin=Origin.CLAUDE_CODE_SESSION, + affected_paths=(deleted_path,), + cwd_path=None, + branch_names=(), + command=None, + query=None, + url=None, + output_text=None, + search_text="deleted repository path", + raw={}, + ) + + attribution = extract_attribution_from_actions([action]) + + assert attribution.file_paths_touched == (deleted_path,) + assert attribution.languages_detected == ("python",) + + def test_extract_attribution_does_not_infer_r_from_dialogue_text() -> None: session = Session( id=SessionId("conv-dialogue-r-noise"), diff --git a/tests/unit/daemon/test_convergence_stages.py b/tests/unit/daemon/test_convergence_stages.py index b2db41ecda..668d96a284 100644 --- a/tests/unit/daemon/test_convergence_stages.py +++ b/tests/unit/daemon/test_convergence_stages.py @@ -992,6 +992,7 @@ def fail_if_used(coro: object) -> object: monkeypatch.setattr("polylogue.daemon.convergence_stages._hot_insight_session_ids", lambda _conn, _ids: set()) monkeypatch.setattr("polylogue.storage.sqlite.connection.open_connection", fake_open_connection) monkeypatch.setattr("polylogue.storage.insights.session.rebuild.rebuild_session_insights_sync", fake_rebuild) + monkeypatch.setattr(stages, "_record_fts_freshness_after_insights", lambda _conn: None) assert make_insights_stage(db_path).execute(tmp_path / "source.jsonl") is True assert opened_paths == [db_path] @@ -1336,6 +1337,7 @@ def fake_rebuild( monkeypatch.setattr("polylogue.storage.sqlite.connection.open_connection", fake_open_connection) monkeypatch.setattr("polylogue.storage.insights.session.rebuild.rebuild_session_insights_sync", fake_rebuild) + monkeypatch.setattr(stages, "_record_fts_freshness_after_insights", lambda _conn: None) monkeypatch.setattr( stages, "_session_ids_for_source_paths",