diff --git a/polylogue/storage/sqlite/archive_tiers/archive.py b/polylogue/storage/sqlite/archive_tiers/archive.py index 9f714ec2b1..b8683b978c 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive.py +++ b/polylogue/storage/sqlite/archive_tiers/archive.py @@ -8350,14 +8350,26 @@ def row_int(key: str) -> int: session_id = str(row["session_id"]) message_count = int(row["message_count"] or 0) raw_title = str(row["title"]) if row["title"] is not None else None - provider_title = raw_title if raw_title and raw_title.strip() else None raw_title_source = str(row["title_source"]) if row["title_source"] is not None else None + # A non-blank ``sessions.title`` is only a genuine provider/derived title + # when ``title_source`` says so. ``title_source='unknown'`` rows still + # carry a NON-NULL title -- the writer's pre-cijx.4 fallback stores the + # raw native id there (a bare UUID, or ":agent-" for a + # subagent), which is exactly the "worse than the UUID it replaces" case + # decision 3 exists to fix. Measured live: 7,501 of 15,401 root sessions + # (48.7%) carry title_source='unknown' -- checking only "is title + # non-blank" (the pre-fix condition) made the structural-label fallback + # dead code for all of them. ``title_source='path'`` is the structural + # label's own prior output; treating it as "not a real title" keeps this + # idempotent on rebuild instead of freezing a stale message count. + has_real_title = bool(raw_title and raw_title.strip()) and raw_title_source in {"origin", "heuristic", "user"} + provider_title = raw_title if has_real_title else None if provider_title is not None: title = provider_title title_source = raw_title_source else: - # No provider-supplied title (or a blank one): fall back to the - # structural label (polylogue-cijx.4 decision 3) rather than + # No provider-supplied title (or a blank/synthetic one): fall back to + # the structural label (polylogue-cijx.4 decision 3) rather than # exposing a bare/blank title to CLI/MCP/API surfaces. This is a # read-time projection only -- never written back to sessions.title. title = session_structural_label_for_session( diff --git a/tests/unit/storage/test_title_source_queryable.py b/tests/unit/storage/test_title_source_queryable.py index 501d42da38..dc01fa2d67 100644 --- a/tests/unit/storage/test_title_source_queryable.py +++ b/tests/unit/storage/test_title_source_queryable.py @@ -110,6 +110,65 @@ def test_titleless_session_falls_back_to_structural_label(tmp_path: Path) -> Non assert matched[0].title_source == "path" +def test_unknown_title_source_falls_back_to_structural_label(tmp_path: Path) -> None: + """polylogue-cijx.4 decision 3: ``title_source='unknown'`` is NOT a real + title, even when ``sessions.title`` is non-blank. + + Claude Code's parser (``sources/parsers/claude/code_parser.py``) + initializes ``title`` to the raw composed session id (e.g. + ``":agent-"`` for a subagent) and only promotes + ``title_source`` off ``UNKNOWN`` when a real signal (human message, + ``agent-name``, ``ai-title``, ``custom-title``) is found. So a real + Claude Code row can carry a non-NULL, non-blank ``title`` *and* + ``title_source='unknown'`` simultaneously -- exactly the case decision 3 + exists to fix ("a structural label today reads 'agent- - 27f - + 499m' -- worse than the UUID it replaces"). Before this fix, + ``_summary_from_row`` treated any non-blank title as a real one + regardless of provenance, so the structural-label fallback never fired + for this population (measured live: 48.7% of root sessions). + """ + from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore + + db_path = tmp_path / "index.db" + with ArchiveStore(tmp_path, initialize=True, read_only=False): + pass + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + session = ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="raw-uuid-1234", + title="raw-uuid-1234", # the code_parser.py raw-id fallback shape + title_source=TitleSource.UNKNOWN, + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.USER, + text="hi", + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hi")], + ), + ], + ) + write_parsed_session_to_archive(conn, session) + conn.commit() + finally: + conn.close() + + with ArchiveStore(tmp_path, initialize=False, read_only=True) as archive: + session_id = archive.resolve_session_id("raw-uuid-1234") + summary = archive.read_summary(session_id) + assert summary.title != "raw-uuid-1234" + assert summary.title_source == "path" + + listed = archive.list_summaries(origin="claude-code-session", limit=10, offset=0) + matched = [s for s in listed if s.session_id == session_id] + assert len(matched) == 1 + assert matched[0].title != "raw-uuid-1234" + assert matched[0].title_source == "path" + + @pytest.mark.asyncio async def test_session_filter_summary_exposes_title_source(workspace_env: dict[str, Path]) -> None: """``SessionFilter.list_summaries()`` yields a domain ``SessionSummary`` with ``title_source`` set."""