diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d3a66c41ef..f6294864a4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -298,9 +298,9 @@ Usage: polylogue read [OPTIONS] [REF] Projection: -v, --view VIEW[,VIEW...] What to render (summary, transcript, dialogue, messages, raw, hooks, events, - context, context-image, neighbors, - correlation, temporal, chronicle). - [default: summary] + file-edits, agent-policies, context, + context-image, neighbors, correlation, + temporal, chronicle). [default: summary] --render TEXT Render expression, e.g. layout:context- image,timestamps:include- available,format:markdown. Known keys: diff --git a/docs/plans/topology-target.yaml b/docs/plans/topology-target.yaml index 5d8ed07040..5d8377e808 100644 --- a/docs/plans/topology-target.yaml +++ b/docs/plans/topology-target.yaml @@ -70,7 +70,7 @@ files: owner: stable cross_cut: { api: async } - path: polylogue/api/archive.py - loc: 7122 + loc: 7214 target: polylogue/api/archive.py owner: stable cross_cut: { api: async } @@ -179,7 +179,7 @@ files: owner: archive-artifact-taxonomy reason: archive-domain semantics - path: polylogue/archive/artifact_taxonomy/runtime.py - loc: 477 + loc: 497 target: polylogue/archive/artifact_taxonomy/runtime.py owner: archive-artifact-taxonomy reason: archive-domain semantics @@ -632,13 +632,13 @@ files: owner: archive-session reason: archive-domain semantics - path: polylogue/archive/session/domain_models.py - loc: 143 + loc: 152 target: polylogue/archive/session/domain_models.py owner: archive-session reason: archive-domain semantics cross_cut: { lifecycle: model } - path: polylogue/archive/session/domain_runtime.py - loc: 233 + loc: 240 target: polylogue/archive/session/domain_runtime.py owner: archive-session reason: archive-domain semantics @@ -689,7 +689,7 @@ files: owner: archive-session reason: archive-domain semantics - path: polylogue/archive/session/summary_runtime.py - loc: 70 + loc: 74 target: polylogue/archive/session/summary_runtime.py owner: archive-session reason: archive-domain semantics @@ -731,7 +731,7 @@ files: owner: archive-viewport reason: archive-domain semantics - path: polylogue/archive/viewport/profiles.py - loc: 383 + loc: 418 target: polylogue/archive/viewport/profiles.py owner: archive-viewport reason: archive-domain semantics @@ -819,7 +819,7 @@ files: target: polylogue/cli/__main__.py owner: stable - path: polylogue/cli/archive_query.py - loc: 2784 + loc: 2846 target: polylogue/cli/archive_query.py owner: stable - path: polylogue/cli/click_app.py @@ -891,7 +891,7 @@ files: target: polylogue/cli/commands/demo.py owner: stable - path: polylogue/cli/commands/diagnostics.py - loc: 996 + loc: 1082 target: polylogue/cli/commands/diagnostics.py owner: stable - path: polylogue/cli/commands/embed.py @@ -1063,7 +1063,7 @@ files: target: polylogue/cli/machine_main.py owner: stable - path: polylogue/cli/messages.py - loc: 269 + loc: 367 target: polylogue/cli/messages.py owner: stable - path: polylogue/cli/onboarding.py @@ -1123,11 +1123,11 @@ files: target: polylogue/cli/query_verbs.py owner: stable - path: polylogue/cli/read_view_handlers.py - loc: 247 + loc: 260 target: polylogue/cli/read_view_handlers.py owner: stable - path: polylogue/cli/read_view_registry.py - loc: 114 + loc: 116 target: polylogue/cli/read_view_registry.py owner: stable - path: polylogue/cli/read_views/__init__.py @@ -1154,6 +1154,10 @@ files: loc: 73 target: polylogue/cli/read_views/events.py owner: stable + - path: polylogue/cli/read_views/file_edits.py + loc: 92 + target: polylogue/cli/read_views/file_edits.py + owner: stable - path: polylogue/cli/read_views/messages.py loc: 262 target: polylogue/cli/read_views/messages.py @@ -1167,7 +1171,7 @@ files: target: polylogue/cli/read_views/query_set.py owner: stable - path: polylogue/cli/read_views/standard.py - loc: 504 + loc: 508 target: polylogue/cli/read_views/standard.py owner: stable - path: polylogue/cli/read_views/streaming_markdown.py @@ -2113,7 +2117,7 @@ files: target: polylogue/insights/timeline_renderer.py owner: stable - path: polylogue/insights/tool_usage.py - loc: 203 + loc: 207 target: polylogue/insights/tool_usage.py owner: stable - path: polylogue/insights/topology.py @@ -2314,7 +2318,7 @@ files: target: polylogue/mcp/server.py owner: stable - path: polylogue/mcp/server_cutover.py - loc: 2345 + loc: 2370 target: polylogue/mcp/server_cutover.py owner: stable - path: polylogue/mcp/server_prompts.py @@ -3355,7 +3359,7 @@ files: target: polylogue/sources/origin_specs.py owner: stable - path: polylogue/sources/parsers/antigravity.py - loc: 473 + loc: 518 target: polylogue/sources/parsers/antigravity.py owner: stable - path: polylogue/sources/parsers/base.py @@ -3544,7 +3548,7 @@ files: target: polylogue/sources/source_acquisition_components.py owner: stable - path: polylogue/sources/source_parsing.py - loc: 316 + loc: 430 target: polylogue/sources/source_parsing.py owner: stable - path: polylogue/sources/source_walk.py @@ -3733,7 +3737,7 @@ files: target: polylogue/storage/fts/sql.py owner: stable - path: polylogue/storage/hydrators.py - loc: 252 + loc: 254 target: polylogue/storage/hydrators.py owner: storage-root reason: storage-root cross-cutting helper @@ -4104,7 +4108,7 @@ files: target: polylogue/storage/sqlite/archive_tiers/__init__.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/archive.py - loc: 11382 + loc: 11416 target: polylogue/storage/sqlite/archive_tiers/archive.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/archive_init.py @@ -4308,7 +4312,7 @@ files: target: polylogue/storage/sqlite/queries/cursor.py owner: stable - path: polylogue/storage/sqlite/queries/file_edits.py - loc: 109 + loc: 77 target: polylogue/storage/sqlite/queries/file_edits.py owner: stable - path: polylogue/storage/sqlite/queries/filter_builder.py @@ -4383,7 +4387,7 @@ files: owner: stable cross_cut: { layer: write } - path: polylogue/storage/sqlite/queries/session_agent_policies.py - loc: 120 + loc: 93 target: polylogue/storage/sqlite/queries/session_agent_policies.py owner: stable - path: polylogue/storage/sqlite/queries/session_events.py @@ -4433,7 +4437,7 @@ files: target: polylogue/storage/sqlite/queries/session_links.py owner: stable - path: polylogue/storage/sqlite/queries/session_refs.py - loc: 90 + loc: 73 target: polylogue/storage/sqlite/queries/session_refs.py owner: stable - path: polylogue/storage/sqlite/queries/sessions.py @@ -4535,7 +4539,7 @@ files: target: TBD owner: storage-domain - path: polylogue/storage/usage.py - loc: 2546 + loc: 2608 target: polylogue/storage/usage.py owner: storage-root reason: storage-root cross-cutting helper @@ -4552,11 +4556,11 @@ files: target: polylogue/surfaces/chronicle.py owner: stable - path: polylogue/surfaces/payloads.py - loc: 3911 + loc: 3912 target: polylogue/surfaces/payloads.py owner: stable - path: polylogue/surfaces/projection_spec.py - loc: 293 + loc: 297 target: polylogue/surfaces/projection_spec.py owner: stable - path: polylogue/surfaces/temporal_evidence.py diff --git a/polylogue/api/archive.py b/polylogue/api/archive.py index c74c36c9f4..dd3e6dd6ea 100644 --- a/polylogue/api/archive.py +++ b/polylogue/api/archive.py @@ -1995,6 +1995,7 @@ def _archive_summary_to_domain(summary: ArchiveSessionSummary) -> SessionSummary git_branch=summary.git_branch, git_repository_url=summary.git_repository_url, provider_project_ref=summary.provider_project_ref, + display_name=summary.display_name, message_count=summary.message_count, tags_m2m=summary.tags, ) @@ -5447,6 +5448,76 @@ async def get_session_events( for event in events ] + async def get_file_edits(self, session_id: str) -> list[dict[str, object]] | None: + """Return file-edit tool-call evidence (structuredPatch/originalFile/...) for one session. + + polylogue-nua7: the writer materializes ``ParsedFileEdit`` evidence + (Claude Code Edit/Write/MultiEdit tool calls -- structured unified + diffs, pre-edit file content, old/new string pairs) into the + dedicated ``file_edits`` index table on every ingest + (``storage/repository/archive/sessions.py::get_file_edits``), but + before this reader nothing above the storage layer could reach it. + This is the read surface: what a "what did this session change" + report needs instead of re-deriving edits from tool-call prose. + + Returns ``None`` when the session does not exist (distinct from an + empty list, meaning the session exists but made no captured edits). + """ + resolved = await self.repository.resolve_id(session_id) + resolved_id = str(resolved) if resolved is not None else session_id + session = await self.repository.get(resolved_id) + if session is None: + return None + edits = await self.repository.get_file_edits(resolved_id) + return [ + { + "tool_use_block_id": edit.tool_use_block_id, + "message_id": str(edit.message_id), + "file_path": edit.file_path, + "structured_patch": edit.structured_patch, + "original_file": edit.original_file, + "old_string": edit.old_string, + "new_string": edit.new_string, + "replace_all": edit.replace_all, + "user_modified": edit.user_modified, + "observed_at_ms": edit.observed_at_ms, + } + for edit in edits + ] + + async def get_agent_policies(self, session_id: str) -> list[dict[str, object]] | None: + """Return sandbox/approval/network policy facts recorded for one session. + + polylogue-nua7: the writer diverts Codex ``agent_policy`` events out + of ``session_events`` into the dedicated ``session_agent_policies`` + table (fully re-derivable, zero evidence loss -- see + ``archive_tiers/write.py:_SESSION_EVENTS_REDUNDANT_TYPES``), but + before this reader nothing above the storage layer could reach it + back. This is the read surface. + + Returns ``None`` when the session does not exist (distinct from an + empty list, meaning the session exists but reported no agent-policy + facts -- expected for non-Codex origins). + """ + resolved = await self.repository.resolve_id(session_id) + resolved_id = str(resolved) if resolved is not None else session_id + session = await self.repository.get(resolved_id) + if session is None: + return None + policies = await self.repository.get_agent_policies(resolved_id) + return [ + { + "policy_id": policy.policy_id, + "position": policy.position, + "approval_policy": policy.approval_policy, + "sandbox_policy": policy.sandbox_policy, + "network_policy": policy.network_policy, + "observed_at_ms": policy.observed_at_ms, + "source_message_id": policy.source_message_id, + } + for policy in policies + ] + async def query_sessions( self, *, diff --git a/polylogue/archive/session/domain_models.py b/polylogue/archive/session/domain_models.py index 0adb93b966..9e40e9ff6c 100644 --- a/polylogue/archive/session/domain_models.py +++ b/polylogue/archive/session/domain_models.py @@ -49,6 +49,13 @@ class SessionSummary(SessionSummaryRuntimeMixin, BaseModel): git_branch: str | None = None git_repository_url: str | None = None provider_project_ref: str | None = None + # Provider-assigned human-readable session name distinct from the + # (possibly inferred) title -- e.g. Claude Code's "slug" wire field + # ("greedy-squishing-hamming"), captured but previously dropped before + # reaching any domain model (polylogue-cgfy: 1,500 sampled occurrences, + # the fix for subagent rows displaying ":agent-" instead + # of a human name). + display_name: str | None = None parent_id: SessionId | None = None branch_type: BranchType | None = None message_count: int | None = None @@ -104,6 +111,8 @@ class Session(SessionRuntimeMixin, BaseModel): git_branch: str | None = None git_repository_url: str | None = None provider_project_ref: str | None = None + # See ``SessionSummary.display_name`` (polylogue-cgfy). + display_name: str | None = None session_events: tuple[SessionEvent, ...] = () parent_id: SessionId | None = None branch_type: BranchType | None = None diff --git a/polylogue/archive/session/domain_runtime.py b/polylogue/archive/session/domain_runtime.py index db413d09c3..6a872721dd 100644 --- a/polylogue/archive/session/domain_runtime.py +++ b/polylogue/archive/session/domain_runtime.py @@ -41,6 +41,7 @@ class SessionRuntimeMixin: metadata: dict[str, object] parent_id: SessionId | None branch_type: BranchType | None + display_name: str | None if TYPE_CHECKING: @@ -73,6 +74,12 @@ def display_title(self) -> str: return user_title if self.title: return self.title + # polylogue-cgfy: provider-assigned display name (e.g. Claude Code's + # slug, "greedy-squishing-hamming") beats the raw id truncation -- + # the fix for subagent rows showing "" instead of a + # human-readable name when no title-worthy sidecar evidence exists. + if self.display_name: + return self.display_name return self.id[:8] @property diff --git a/polylogue/archive/session/summary_runtime.py b/polylogue/archive/session/summary_runtime.py index a176fab7bc..27c2e0a8cc 100644 --- a/polylogue/archive/session/summary_runtime.py +++ b/polylogue/archive/session/summary_runtime.py @@ -28,6 +28,7 @@ class SessionSummaryRuntimeMixin: metadata: dict[str, object] parent_id: SessionId | None branch_type: BranchType | None + display_name: str | None @property def display_date(self) -> datetime | None: @@ -40,6 +41,9 @@ def display_title(self) -> str: return user_title if self.title: return self.title + # polylogue-cgfy: see Session.display_title's twin fallback. + if self.display_name: + return self.display_name return self.id[:8] @property diff --git a/polylogue/archive/viewport/profiles.py b/polylogue/archive/viewport/profiles.py index 368e018d79..8837698325 100644 --- a/polylogue/archive/viewport/profiles.py +++ b/polylogue/archive/viewport/profiles.py @@ -178,6 +178,41 @@ def to_payload(self) -> JSONDocument: machine_payload="session event list payload", degraded_states=("missing session", "session with no session_events"), ), + SessionViewProfile( + view_id="file-edits", + label="File Edits", + owner="polylogue.cli.read_views.file_edits.run_read_file_edits", + purpose=( + "Captured Claude Code Edit/Write/MultiEdit tool-call evidence: structured unified diffs " + "(structured_patch), pre-edit file content (original_file), and old/new string pairs -- " + "the typed 'what did this session change' data (polylogue-nua7/polylogue-cgfy)." + ), + input_scope="single session id", + included_kinds=("file path", "structured patch", "original file", "old/new string pair"), + lossiness="raw", + evidence_policy="required", + privacy_policy="renders the substrate's own structured file-edit payload verbatim, bounded by the source parser", + formats=("json",), + machine_payload="file edit list payload", + degraded_states=("missing session", "session with no captured file edits"), + ), + SessionViewProfile( + view_id="agent-policies", + label="Agent Policies", + owner="polylogue.cli.read_views.file_edits.run_read_agent_policies", + purpose=( + "Sandbox/approval/network policy facts (e.g. Codex agent_policy events), diverted out of " + "session_events into a dedicated table for zero-loss re-derivation (polylogue-nua7)." + ), + input_scope="single session id", + included_kinds=("approval policy", "sandbox policy", "network policy"), + lossiness="raw", + evidence_policy="required", + privacy_policy="renders the substrate's own structured policy payload verbatim, bounded by the source parser", + formats=("json",), + machine_payload="agent policy list payload", + degraded_states=("missing session", "session with no recorded agent-policy facts"), + ), SessionViewProfile( view_id="context", label="Context", diff --git a/polylogue/cli/messages.py b/polylogue/cli/messages.py index 3f8a593a74..4461af5365 100644 --- a/polylogue/cli/messages.py +++ b/polylogue/cli/messages.py @@ -266,4 +266,102 @@ async def _run() -> None: run_coroutine_sync(_run()) -__all__ = ["run_hooks", "run_messages", "run_raw", "run_session_events"] +def run_session_file_edits( + env: AppEnv, + request: RootModeRequest, + *, + session_id: str, + output_format: str = "json", +) -> None: + """Execute the file-edits verb. + + Renders captured Claude Code Edit/Write/MultiEdit tool-call evidence + (polylogue-nua7/polylogue-cgfy): structured unified diffs + (``structured_patch``), pre-edit file content (``original_file``), and + old/new string pairs -- persisted on every ingest into the dedicated + ``file_edits`` table but, before this view, unreachable from any + surface. This is the "what did this session change" evidence a report + needs instead of re-deriving edits from tool-call prose. + """ + from polylogue.api import Polylogue + + async def _run() -> None: + async with Polylogue.open(config=cast(Config, request.params.get("_config"))) as api: + edits = await api.get_file_edits(session_id) + + if edits is None: + env.ui.error(f"Session not found: {session_id}") + return + + payload = { + "session_id": session_id, + "total": len(edits), + "file_edits": edits, + } + + if output_format == "json": + import json as _json + + # Machine output uses raw stdout so Rich markup never rewrites + # JSON bytes and read-view delivery can capture file/clipboard + # targets consistently. + click.echo(_json.dumps(payload, indent=2)) + else: + import yaml + + click.echo(yaml.dump(payload)) + + run_coroutine_sync(_run()) + + +def run_session_agent_policies( + env: AppEnv, + request: RootModeRequest, + *, + session_id: str, + output_format: str = "json", +) -> None: + """Execute the agent-policies verb. + + Renders sandbox/approval/network policy facts (polylogue-nua7) -- the + writer diverts Codex ``agent_policy`` events out of ``session_events`` + into the dedicated ``session_agent_policies`` table for zero-loss + re-derivation, but before this view nothing above the storage layer + could read them back. + """ + from polylogue.api import Polylogue + + async def _run() -> None: + async with Polylogue.open(config=cast(Config, request.params.get("_config"))) as api: + policies = await api.get_agent_policies(session_id) + + if policies is None: + env.ui.error(f"Session not found: {session_id}") + return + + payload = { + "session_id": session_id, + "total": len(policies), + "agent_policies": policies, + } + + if output_format == "json": + import json as _json + + click.echo(_json.dumps(payload, indent=2)) + else: + import yaml + + click.echo(yaml.dump(payload)) + + run_coroutine_sync(_run()) + + +__all__ = [ + "run_hooks", + "run_messages", + "run_raw", + "run_session_agent_policies", + "run_session_events", + "run_session_file_edits", +] diff --git a/polylogue/cli/read_view_handlers.py b/polylogue/cli/read_view_handlers.py index 03d1df3721..6493499d8f 100644 --- a/polylogue/cli/read_view_handlers.py +++ b/polylogue/cli/read_view_handlers.py @@ -38,6 +38,7 @@ ) from polylogue.cli.read_views.correlation import build_correlation_options, run_read_correlation from polylogue.cli.read_views.events import build_events_options, run_read_events +from polylogue.cli.read_views.file_edits import run_read_agent_policies, run_read_file_edits from polylogue.cli.read_views.messages import ( build_message_options, run_read_hooks, @@ -105,6 +106,18 @@ accepted_options=EVENTS_READ_VIEW_OPTION_NAMES, option_builder=build_events_options, ), + "file-edits": ReadViewHandler( + "file-edits", + "required", + run_read_file_edits, + default_format="json", + ), + "agent-policies": ReadViewHandler( + "agent-policies", + "required", + run_read_agent_policies, + default_format="json", + ), "context": ReadViewHandler( "context", "required", diff --git a/polylogue/cli/read_view_registry.py b/polylogue/cli/read_view_registry.py index d275298859..8eab0bf809 100644 --- a/polylogue/cli/read_view_registry.py +++ b/polylogue/cli/read_view_registry.py @@ -56,6 +56,8 @@ class ReadViewHandlerMetadata: "raw": ReadViewHandlerMetadata("raw", "required", MESSAGE_READ_VIEW_OPTION_NAMES), "hooks": ReadViewHandlerMetadata("hooks", "required"), "events": ReadViewHandlerMetadata("events", "required", EVENTS_READ_VIEW_OPTION_NAMES), + "file-edits": ReadViewHandlerMetadata("file-edits", "required"), + "agent-policies": ReadViewHandlerMetadata("agent-policies", "required"), "context": ReadViewHandlerMetadata("context", "required", CONTEXT_READ_VIEW_OPTION_NAMES), "context-image": ReadViewHandlerMetadata("context-image", "none", CONTEXT_IMAGE_READ_VIEW_OPTION_NAMES), "neighbors": ReadViewHandlerMetadata("neighbors", "query_or_session", NEIGHBOR_READ_VIEW_OPTION_NAMES), diff --git a/polylogue/cli/read_views/file_edits.py b/polylogue/cli/read_views/file_edits.py new file mode 100644 index 0000000000..9383ee1f48 --- /dev/null +++ b/polylogue/cli/read_views/file_edits.py @@ -0,0 +1,92 @@ +"""File-edit and agent-policy evidence read-view handlers. + +Renders two index-tier relations that had a complete, tested read chain +terminating at the repository layer with no surface consumer above it +(polylogue-nua7): ``file_edits`` (Claude Code Edit/Write/MultiEdit +structured diffs / pre-edit file content / old-new string pairs) and +``session_agent_policies`` (Codex sandbox/approval/network policy facts). +""" + +from __future__ import annotations + +import io + +import click + +from polylogue.cli.read_views.base import ReadViewInvocation, deliver_content +from polylogue.cli.root_request import RootModeRequest +from polylogue.cli.shared.types import AppEnv + +__all__ = ["run_read_agent_policies", "run_read_file_edits"] + + +def run_read_file_edits(env: AppEnv, request: RootModeRequest, invocation: ReadViewInvocation) -> None: + """Route the file-edits view to the file-edit evidence renderer.""" + + from polylogue.cli.messages import run_session_file_edits + + assert invocation.session_id is not None + output_format = invocation.output_format or "json" + + if invocation.destination in ("file", "clipboard", "stdout"): + buf = io.StringIO() + + def _captured_echo(message: object = None, **_kwargs: object) -> None: + buf.write(str(message or "") + "\n") + + _orig_echo = click.echo + click.echo = _captured_echo # type: ignore[assignment] + try: + run_session_file_edits( + env, + request, + session_id=invocation.session_id, + output_format=output_format, + ) + finally: + click.echo = _orig_echo + deliver_content(env, buf.getvalue(), destination=invocation.destination, out_path=invocation.out_path) + return + + run_session_file_edits( + env, + request, + session_id=invocation.session_id, + output_format=output_format, + ) + + +def run_read_agent_policies(env: AppEnv, request: RootModeRequest, invocation: ReadViewInvocation) -> None: + """Route the agent-policies view to the agent-policy evidence renderer.""" + + from polylogue.cli.messages import run_session_agent_policies + + assert invocation.session_id is not None + output_format = invocation.output_format or "json" + + if invocation.destination in ("file", "clipboard", "stdout"): + buf = io.StringIO() + + def _captured_echo(message: object = None, **_kwargs: object) -> None: + buf.write(str(message or "") + "\n") + + _orig_echo = click.echo + click.echo = _captured_echo # type: ignore[assignment] + try: + run_session_agent_policies( + env, + request, + session_id=invocation.session_id, + output_format=output_format, + ) + finally: + click.echo = _orig_echo + deliver_content(env, buf.getvalue(), destination=invocation.destination, out_path=invocation.out_path) + return + + run_session_agent_policies( + env, + request, + session_id=invocation.session_id, + output_format=output_format, + ) diff --git a/polylogue/mcp/server_cutover.py b/polylogue/mcp/server_cutover.py index 61aa5789d5..f39eaf43e9 100644 --- a/polylogue/mcp/server_cutover.py +++ b/polylogue/mcp/server_cutover.py @@ -868,6 +868,17 @@ async def get(ref: str, projection: str | None = None) -> str: tool-availability spans, and similar provider evidence that rides the timeline rather than a dialogue message. + ``projection="file-edits"`` returns captured Claude Code Edit/Write/ + MultiEdit tool-call evidence for the session -- structured unified + diffs (``structured_patch``), pre-edit file content + (``original_file``), and old/new string pairs -- the typed "what did + this session change" data instead of inferring it from tool-call + prose. + + ``projection="agent-policies"`` returns sandbox/approval/network + policy facts (e.g. Codex ``agent_policy`` events) recorded on the + session's own timeline. + ``ref="cost-outlook:"`` projects the current billing cycle for a configured subscription plan (the standalone ``cost_outlook`` MCP tool retired by the six-tool cutover, #3095/polylogue-t46.8, has @@ -898,6 +909,20 @@ async def run() -> str: return hooks.json_payload( MCPRootPayload(root={"session_id": session_id, "total": len(events), "events": events}) ) + if projection == "file-edits" and session_id is not None: + edits = await hooks.get_polylogue().get_file_edits(session_id) + if edits is None: + return hooks.error_json(f"object not found: {ref}", code="not_found", tool="get") + return hooks.json_payload( + MCPRootPayload(root={"session_id": session_id, "total": len(edits), "file_edits": edits}) + ) + if projection == "agent-policies" and session_id is not None: + policies = await hooks.get_polylogue().get_agent_policies(session_id) + if policies is None: + return hooks.error_json(f"object not found: {ref}", code="not_found", tool="get") + return hooks.json_payload( + MCPRootPayload(root={"session_id": session_id, "total": len(policies), "agent_policies": policies}) + ) return hooks.json_payload(await hooks.get_polylogue().resolve_ref(normalized)) return await hooks.async_safe_call("get", run, session_id=session_id) diff --git a/polylogue/storage/hydrators.py b/polylogue/storage/hydrators.py index ec95e9d134..005fc78af4 100644 --- a/polylogue/storage/hydrators.py +++ b/polylogue/storage/hydrators.py @@ -180,6 +180,7 @@ def session_summary_from_record( git_branch=record.git_branch, git_repository_url=record.git_repository_url, provider_project_ref=record.provider_project_ref, + display_name=record.display_name, parent_id=record.parent_session_id, branch_type=record.branch_type, message_count=message_count, @@ -236,6 +237,7 @@ def session_from_records( git_branch=session.git_branch, git_repository_url=session.git_repository_url, provider_project_ref=session.provider_project_ref, + display_name=session.display_name, session_events=tuple(session_event_from_record(event) for event in (session_events or [])), parent_id=session.parent_session_id, branch_type=session.branch_type, diff --git a/polylogue/storage/sqlite/archive_tiers/archive.py b/polylogue/storage/sqlite/archive_tiers/archive.py index 1c93109792..0be63151b4 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive.py +++ b/polylogue/storage/sqlite/archive_tiers/archive.py @@ -392,6 +392,10 @@ class ArchiveSessionSummary: git_branch: str | None = None git_repository_url: str | None = None provider_project_ref: str | None = None + # See ``Session.display_name`` / ``SessionSummary.display_name`` + # (polylogue-cgfy): a provider-assigned name (e.g. Claude Code's slug) + # distinct from the (possibly derived) title. + display_name: str | None = None @dataclass(frozen=True, slots=True) @@ -3755,6 +3759,7 @@ def read_summary(self, session_id: str) -> ArchiveSessionSummary: s.tool_message_count, s.user_word_count, s.authored_user_word_count, s.assistant_word_count, s.title_source, s.title_ref, s.title_confidence, s.git_branch, s.git_repository_url, s.provider_project_ref, + s.display_name, COALESCE( ( SELECT json_group_array(swd.path) @@ -6114,6 +6119,7 @@ def list_summaries( s.tool_message_count, s.user_word_count, s.authored_user_word_count, s.assistant_word_count, s.title_source, s.title_ref, s.title_confidence, s.git_branch, s.git_repository_url, s.provider_project_ref, + s.display_name, COALESCE( ( SELECT json_group_array(swd.path) @@ -8373,14 +8379,32 @@ def row_int(key: str) -> int: # 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 + try: + raw_display_name = row["display_name"] + except IndexError: + # Not every caller's SELECT projects display_name; treat absence as + # unknown rather than raising (matching parent_id's guard below). + display_name: str | None = None + else: + display_name = str(raw_display_name).strip() or None if raw_display_name is not None else None if provider_title is not None: title = provider_title title_source = raw_title_source + elif display_name: + # polylogue-cgfy: a provider-assigned display name (Claude Code's + # slug, e.g. "greedy-squishing-hamming") is real origin evidence -- + # prefer it over the derived structural label below. This is the fix + # for subagent rows rendering as ":agent-" + # instead of a human-readable name when no title sidecar evidence + # exists for that specific session. + title = display_name + title_source = "origin" else: - # 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. + # No provider-supplied title or display name (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( conn, session_id, @@ -8430,6 +8454,7 @@ def row_int(key: str) -> int: git_branch=str(row["git_branch"]) if row["git_branch"] is not None else None, git_repository_url=str(row["git_repository_url"]) if row["git_repository_url"] is not None else None, provider_project_ref=(str(row["provider_project_ref"]) if row["provider_project_ref"] is not None else None), + display_name=display_name, ) diff --git a/polylogue/storage/sqlite/queries/file_edits.py b/polylogue/storage/sqlite/queries/file_edits.py index 04f2f80761..9e428488a7 100644 --- a/polylogue/storage/sqlite/queries/file_edits.py +++ b/polylogue/storage/sqlite/queries/file_edits.py @@ -11,7 +11,6 @@ from __future__ import annotations -import sqlite3 from collections import defaultdict from collections.abc import Sequence @@ -21,10 +20,8 @@ from polylogue.storage.sqlite.queries.mappers import _row_to_file_edit __all__ = [ - "get_file_edit", "get_file_edits_for_session", "get_file_edits_for_session_batch", - "sync_get_file_edits_for_session", ] _SELECT_COLUMNS = ( @@ -33,20 +30,6 @@ ) -async def get_file_edit( - conn: aiosqlite.Connection, - tool_use_block_id: str, -) -> FileEditRecord | None: - """Return the file-edit row for one tool_use block, or ``None``.""" - row = await ( - await conn.execute( - f"SELECT {_SELECT_COLUMNS} FROM file_edits WHERE tool_use_block_id = ?", - (tool_use_block_id,), - ) - ).fetchone() - return _row_to_file_edit(row) if row is not None else None - - async def get_file_edits_for_session( conn: aiosqlite.Connection, session_id: str, @@ -92,18 +75,3 @@ async def get_file_edits_for_session_batch( record = _row_to_file_edit(row) result[str(record.session_id)].append(record) return dict(result) - - -def sync_get_file_edits_for_session(conn: sqlite3.Connection, session_id: str) -> list[FileEditRecord]: - """Sync sibling of :func:`get_file_edits_for_session`.""" - conn.row_factory = sqlite3.Row - rows = conn.execute( - f""" - SELECT {_SELECT_COLUMNS} - FROM file_edits - WHERE session_id = ? - ORDER BY message_id, tool_use_block_id - """, - (session_id,), - ).fetchall() - return [_row_to_file_edit(row) for row in rows] diff --git a/polylogue/storage/sqlite/queries/session_agent_policies.py b/polylogue/storage/sqlite/queries/session_agent_policies.py index 6de0021cb0..2bed8227df 100644 --- a/polylogue/storage/sqlite/queries/session_agent_policies.py +++ b/polylogue/storage/sqlite/queries/session_agent_policies.py @@ -25,7 +25,6 @@ __all__ = [ "get_session_agent_policies", "get_session_agent_policies_batch", - "sync_session_agent_policies_batch", ] _SELECT_COLUMNS = ( @@ -92,29 +91,3 @@ async def get_session_agent_policies_batch( policy = _row_to_agent_policy(row) result[policy.session_id].append(policy) return dict(result) - - -def sync_session_agent_policies_batch( - conn: sqlite3.Connection, - session_ids: Sequence[str], -) -> dict[str, list[ArchiveAgentPolicy]]: - """Sync sibling of :func:`get_session_agent_policies_batch`.""" - if not session_ids: - return {} - placeholders = ", ".join("?" for _ in session_ids) - rows = conn.execute( - f""" - SELECT {_SELECT_COLUMNS} - FROM session_agent_policies - WHERE session_id IN ({placeholders}) - ORDER BY session_id, position - """, - tuple(session_ids), - ).fetchall() - result: dict[str, list[ArchiveAgentPolicy]] = defaultdict(list) - for session_id in session_ids: - result.setdefault(session_id, []) - for row in rows: - policy = _row_to_agent_policy(row) - result[policy.session_id].append(policy) - return dict(result) diff --git a/polylogue/storage/sqlite/queries/session_refs.py b/polylogue/storage/sqlite/queries/session_refs.py index 6cc2aa3882..f367e84611 100644 --- a/polylogue/storage/sqlite/queries/session_refs.py +++ b/polylogue/storage/sqlite/queries/session_refs.py @@ -10,7 +10,6 @@ from __future__ import annotations -import sqlite3 from collections import defaultdict from collections.abc import Sequence @@ -22,7 +21,6 @@ __all__ = [ "get_session_refs", "get_session_refs_batch", - "sync_get_session_refs", ] _SELECT_COLUMNS = "ref_id, session_id, position, kind, repo, ref_number, url, observed_at_ms" @@ -73,18 +71,3 @@ async def get_session_refs_batch( record = _row_to_session_ref(row) result[str(record.session_id)].append(record) return dict(result) - - -def sync_get_session_refs(conn: sqlite3.Connection, session_id: str) -> list[SessionRefRecord]: - """Sync sibling of :func:`get_session_refs`.""" - conn.row_factory = sqlite3.Row - rows = conn.execute( - f""" - SELECT {_SELECT_COLUMNS} - FROM session_refs - WHERE session_id = ? - ORDER BY position - """, - (session_id,), - ).fetchall() - return [_row_to_session_ref(row) for row in rows] diff --git a/polylogue/surfaces/projection_spec.py b/polylogue/surfaces/projection_spec.py index 085f108a98..6bfd38aeb1 100644 --- a/polylogue/surfaces/projection_spec.py +++ b/polylogue/surfaces/projection_spec.py @@ -25,6 +25,8 @@ class EvidenceFamily(str, Enum): RAW = "raw" HOOKS = "hooks" EVENTS = "events" + FILE_EDITS = "file-edits" + AGENT_POLICIES = "agent-policies" CONTEXT = "context" CHRONICLE = "chronicle" NEIGHBORS = "neighbors" @@ -156,6 +158,8 @@ class QueryProjectionSpec(SurfacePayloadModel): "raw": (EvidenceFamily.RAW,), "hooks": (EvidenceFamily.HOOKS,), "events": (EvidenceFamily.EVENTS,), + "file-edits": (EvidenceFamily.FILE_EDITS,), + "agent-policies": (EvidenceFamily.AGENT_POLICIES,), "context": (EvidenceFamily.CONTEXT, EvidenceFamily.MESSAGES), "context-image": (EvidenceFamily.CONTEXT, EvidenceFamily.MESSAGES), "chronicle": (EvidenceFamily.CHRONICLE, EvidenceFamily.SESSIONS, EvidenceFamily.MESSAGES), diff --git a/tests/unit/api/test_facade_contracts.py b/tests/unit/api/test_facade_contracts.py index 52dc3c345e..aed596b765 100644 --- a/tests/unit/api/test_facade_contracts.py +++ b/tests/unit/api/test_facade_contracts.py @@ -85,6 +85,8 @@ "resume_brief", "get_hook_event_summary_for_session", "get_session_events", + "get_file_edits", + "get_agent_policies", } ) diff --git a/tests/unit/cli/test_file_edits_and_agent_policies_views.py b/tests/unit/cli/test_file_edits_and_agent_policies_views.py new file mode 100644 index 0000000000..12cd11e1b7 --- /dev/null +++ b/tests/unit/cli/test_file_edits_and_agent_policies_views.py @@ -0,0 +1,171 @@ +"""End-to-end CLI coverage for the file-edits/agent-policies read views. + +polylogue-nua7: ``file_edits`` (76,105 live rows) and ``session_agent_policies`` +had a complete, tested read chain that terminated at the repository layer with +no surface consumer above it. These tests exercise the real ``read --view +file-edits``/``read --view agent-policies`` verbs end to end -- real +``ArchiveStore`` write, real CLI invocation, real JSON render -- not the +storage function in isolation. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from polylogue.cli.click_app import cli as click_cli + + +@pytest.fixture +def cli_runner() -> CliRunner: + return CliRunner() + + +def test_read_view_file_edits_surfaces_structured_patch( + cli_runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from polylogue.core.enums import BlockType, Provider, Role + from polylogue.sources.parsers.base import ParsedContentBlock, ParsedFileEdit, ParsedMessage, ParsedSession + from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore + + archive_root = tmp_path / "archive" + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(archive_root)) + + with ArchiveStore(archive_root) as archive_db: + parsed = ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="cli-file-edit-1", + title="CLI file-edits view session", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.ASSISTANT, + position=0, + blocks=[ + ParsedContentBlock( + type=BlockType.TOOL_USE, + tool_name="Edit", + tool_id="edit-tool-1", + tool_input={"file_path": "/tmp/foo.py"}, + ), + ], + ), + ParsedMessage( + provider_message_id="m2", + role=Role.USER, + position=1, + blocks=[ + ParsedContentBlock( + type=BlockType.TOOL_RESULT, + tool_id="edit-tool-1", + text="applied", + file_edit=ParsedFileEdit( + file_path="/tmp/foo.py", + structured_patch=[ + {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 2, "lines": ["+x"]} + ], + original_file="old contents\n", + old_string="old", + new_string="new", + replace_all=False, + user_modified=True, + ), + ), + ], + ), + ], + ) + archive_db.write_raw_and_parsed( + parsed, + payload=b'{"raw": "claude payload"}', + source_path="/tmp/raw.jsonl", + acquired_at_ms=1735689600000, + ) + + session_id = "claude-code-session:cli-file-edit-1" + + result = cli_runner.invoke( + click_cli, + ["--plain", "--id", session_id, "read", "--view", "file-edits", "-f", "json"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["total"] == 1 + edit = payload["file_edits"][0] + assert edit["file_path"] == "/tmp/foo.py" + assert edit["original_file"] == "old contents\n" + assert edit["old_string"] == "old" + assert edit["new_string"] == "new" + assert edit["structured_patch"] == [{"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 2, "lines": ["+x"]}] + + missing = cli_runner.invoke( + click_cli, + ["--plain", "--id", "claude-code-session:does-not-exist", "read", "--view", "file-edits"], + catch_exceptions=False, + ) + assert "not found" in missing.output.lower() + + +def test_read_view_agent_policies_surfaces_sandbox_facts( + cli_runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from polylogue.core.enums import BlockType, Provider, Role + from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession, ParsedSessionEvent + from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore + + archive_root = tmp_path / "archive" + monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(archive_root)) + + with ArchiveStore(archive_root) as archive_db: + parsed = ParsedSession( + source_name=Provider.CODEX, + provider_session_id="cli-agent-policy-1", + title="CLI agent-policies view session", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.USER, + text="run it", + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="run it")], + ), + ], + session_events=[ + ParsedSessionEvent( + event_type="agent_policy", + timestamp="2026-01-01T00:00:01+00:00", + payload={ + "approval_policy": "never", + "sandbox_policy": "danger-full-access", + "network_policy": "true", + }, + ), + ], + ) + archive_db.write_raw_and_parsed( + parsed, + payload=b'{"raw": "codex payload"}', + source_path="/tmp/raw.jsonl", + acquired_at_ms=1735689600000, + ) + + session_id = "codex-session:cli-agent-policy-1" + + result = cli_runner.invoke( + click_cli, + ["--plain", "--id", session_id, "read", "--view", "agent-policies", "-f", "json"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["total"] == 1 + policy = payload["agent_policies"][0] + assert policy["approval_policy"] == "never" + assert policy["sandbox_policy"] == "danger-full-access" + assert policy["network_policy"] == "true" diff --git a/tests/unit/mcp/test_server_surfaces.py b/tests/unit/mcp/test_server_surfaces.py index 9b9a29f821..09c59675ff 100644 --- a/tests/unit/mcp/test_server_surfaces.py +++ b/tests/unit/mcp/test_server_surfaces.py @@ -271,3 +271,233 @@ async def test_get_projection_events_surfaces_session_timeline_evidence( ) ) assert missing["code"] == "not_found" + + +@pytest.mark.asyncio +async def test_get_projection_file_edits_surfaces_structured_patch_evidence( + mcp_server: MCPServerUnderTest, tmp_path: Path +) -> None: + """``get(ref, projection="file-edits")`` reaches the ``file_edits`` table + (polylogue-nua7/polylogue-cgfy): structured unified diffs, pre-edit file + content, and old/new string pairs captured on Edit/Write tool calls, but + unreachable from any surface before this projection existed -- the exact + "what did this session change" evidence a postmortem report needs. + """ + from polylogue.core.enums import BlockType, Provider, Role + from polylogue.sources.parsers.base import ParsedContentBlock, ParsedFileEdit, ParsedMessage, ParsedSession + + archive_root = tmp_path / "archive" + with ArchiveStore(archive_root) as archive_db: + parsed = ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="mcp-file-edits-ref", + title="MCP file-edits projection", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.ASSISTANT, + position=0, + blocks=[ + ParsedContentBlock( + type=BlockType.TOOL_USE, + tool_name="Edit", + tool_id="edit-tool-1", + tool_input={"file_path": "/tmp/foo.py"}, + ), + ], + ), + ParsedMessage( + provider_message_id="m2", + role=Role.USER, + position=1, + blocks=[ + ParsedContentBlock( + type=BlockType.TOOL_RESULT, + tool_id="edit-tool-1", + text="applied", + file_edit=ParsedFileEdit( + file_path="/tmp/foo.py", + structured_patch=[ + {"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 2, "lines": ["+x"]} + ], + original_file="old contents\n", + old_string="old", + new_string="new", + replace_all=False, + user_modified=True, + ), + ), + ], + ), + ], + ) + archive_db.write_raw_and_parsed( + parsed, + payload=b'{"raw": "claude payload"}', + source_path="/tmp/raw.jsonl", + acquired_at_ms=1735689600000, + ) + + uri = "polylogue://session/claude-code-session:mcp-file-edits-ref" + from polylogue import Polylogue + + with ( + patch("polylogue.mcp.server._get_config", return_value=SimpleNamespace(archive_root=archive_root)), + patch("polylogue.mcp.server._get_polylogue", return_value=Polylogue(archive_root=archive_root)), + ): + payload = json.loads( + await invoke_surface_async(mcp_server._tool_manager._tools["get"].fn, ref=uri, projection="file-edits") + ) + default_payload = json.loads(await invoke_surface_async(mcp_server._tool_manager._tools["get"].fn, ref=uri)) + + assert payload["total"] == 1 + edit = payload["file_edits"][0] + assert edit["file_path"] == "/tmp/foo.py" + assert edit["original_file"] == "old contents\n" + assert edit["old_string"] == "old" + assert edit["new_string"] == "new" + assert edit["structured_patch"] == [{"oldStart": 1, "oldLines": 1, "newStart": 1, "newLines": 2, "lines": ["+x"]}] + assert "file_edits" not in default_payload + + with ( + patch("polylogue.mcp.server._get_config", return_value=SimpleNamespace(archive_root=archive_root)), + patch("polylogue.mcp.server._get_polylogue", return_value=Polylogue(archive_root=archive_root)), + ): + missing = json.loads( + await invoke_surface_async( + mcp_server._tool_manager._tools["get"].fn, + ref="polylogue://session/claude-code-session:does-not-exist", + projection="file-edits", + ) + ) + assert missing["code"] == "not_found" + + +@pytest.mark.asyncio +async def test_get_projection_agent_policies_surfaces_sandbox_facts( + mcp_server: MCPServerUnderTest, tmp_path: Path +) -> None: + """``get(ref, projection="agent-policies")`` reaches the dedicated + ``session_agent_policies`` table (polylogue-nua7) -- Codex sandbox/ + approval/network policy facts the writer diverts out of + ``session_events`` for zero-loss re-derivation, but which had zero + surface consumers before this projection. + """ + from polylogue.core.enums import BlockType, Provider, Role + from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession, ParsedSessionEvent + + archive_root = tmp_path / "archive" + with ArchiveStore(archive_root) as archive_db: + parsed = ParsedSession( + source_name=Provider.CODEX, + provider_session_id="mcp-agent-policies-ref", + title="MCP agent-policies projection", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.USER, + text="run it", + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="run it")], + ), + ], + session_events=[ + ParsedSessionEvent( + event_type="agent_policy", + timestamp="2026-01-01T00:00:01+00:00", + payload={ + "approval_policy": "never", + "sandbox_policy": "danger-full-access", + "network_policy": "true", + }, + ), + ], + ) + archive_db.write_raw_and_parsed( + parsed, + payload=b'{"raw": "codex payload"}', + source_path="/tmp/raw.jsonl", + acquired_at_ms=1735689600000, + ) + + uri = "polylogue://session/codex-session:mcp-agent-policies-ref" + from polylogue import Polylogue + + with ( + patch("polylogue.mcp.server._get_config", return_value=SimpleNamespace(archive_root=archive_root)), + patch("polylogue.mcp.server._get_polylogue", return_value=Polylogue(archive_root=archive_root)), + ): + payload = json.loads( + await invoke_surface_async(mcp_server._tool_manager._tools["get"].fn, ref=uri, projection="agent-policies") + ) + default_payload = json.loads(await invoke_surface_async(mcp_server._tool_manager._tools["get"].fn, ref=uri)) + + assert payload["total"] == 1 + policy = payload["agent_policies"][0] + assert policy["approval_policy"] == "never" + assert policy["sandbox_policy"] == "danger-full-access" + assert policy["network_policy"] == "true" + assert "agent_policies" not in default_payload + + with ( + patch("polylogue.mcp.server._get_config", return_value=SimpleNamespace(archive_root=archive_root)), + patch("polylogue.mcp.server._get_polylogue", return_value=Polylogue(archive_root=archive_root)), + ): + missing = json.loads( + await invoke_surface_async( + mcp_server._tool_manager._tools["get"].fn, + ref="polylogue://session/codex-session:does-not-exist", + projection="agent-policies", + ) + ) + assert missing["code"] == "not_found" + + +@pytest.mark.asyncio +async def test_get_default_projection_surfaces_display_name_when_title_absent( + mcp_server: MCPServerUnderTest, tmp_path: Path +) -> None: + """``get(ref)`` (no projection) reaches ``sessions.display_name`` + (polylogue-cgfy): a session with no title-worthy sidecar evidence (the + common Claude Code subagent case) now surfaces its provider-assigned + slug as the title instead of a raw session id -- read through the real + ``ArchiveStore``-backed summary path (``_resolve_session_object_ref`` -> + ``_archive_summary_to_domain`` -> ``SessionSummaryPayload``), not the + storage row in isolation. + """ + from polylogue.core.enums import BlockType, Provider, Role + from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession + + archive_root = tmp_path / "archive" + with ArchiveStore(archive_root) as archive_db: + parsed = ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="mcp-slug-only-ref", + title=None, + display_name="greedy-squishing-hamming", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.ASSISTANT, + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hi")], + ), + ], + ) + archive_db.write_raw_and_parsed( + parsed, + payload=b'{"raw": "claude payload"}', + source_path="/tmp/raw.jsonl", + acquired_at_ms=1735689600000, + ) + + uri = "polylogue://session/claude-code-session:mcp-slug-only-ref" + from polylogue import Polylogue + + with ( + patch("polylogue.mcp.server._get_config", return_value=SimpleNamespace(archive_root=archive_root)), + patch("polylogue.mcp.server._get_polylogue", return_value=Polylogue(archive_root=archive_root)), + ): + payload = json.loads(await invoke_surface_async(mcp_server._tool_manager._tools["get"].fn, ref=uri)) + + assert payload["title"] == "greedy-squishing-hamming" diff --git a/tests/unit/storage/test_session_display_name_reaches_repository.py b/tests/unit/storage/test_session_display_name_reaches_repository.py new file mode 100644 index 0000000000..595f077e43 --- /dev/null +++ b/tests/unit/storage/test_session_display_name_reaches_repository.py @@ -0,0 +1,90 @@ +"""``display_name`` (the Claude Code ``slug`` wire field) reaches Session/SessionSummary. + +polylogue-cgfy: the parser has captured ``slug`` (1,500 sampled occurrences) +into ``ParsedSession.display_name`` and the writer persists it into the +``sessions.display_name`` column since polylogue-2qx.4, but neither +``Session`` nor ``SessionSummary`` carried a ``display_name`` field at all -- +the value was written durably and then dropped on every read, so a session +whose only title-worthy evidence was its slug (the common subagent case, +":agent-" instead of a human name) still rendered as a +raw id/UUID everywhere. This test proves the real writer -> real async +repository -> domain-model ``display_title`` chain now surfaces it, not the +storage column in isolation. +""" + +from __future__ import annotations + +from pathlib import Path + +from polylogue.core.enums import BlockType, Provider, Role +from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession +from polylogue.storage.repository import SessionRepository +from polylogue.storage.sqlite.async_sqlite import SQLiteBackend +from tests.infra.live_ingest import ingest_session + + +async def test_display_name_reaches_session_display_title_when_title_absent(tmp_path: Path) -> None: + """A session with no title-worthy evidence falls back to its slug, not a raw id.""" + backend = SQLiteBackend(db_path=tmp_path / "display-name.db") + repo = SessionRepository(backend=backend) + try: + session_id = await ingest_session( + ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="slug-only-session", + title=None, + display_name="greedy-squishing-hamming", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.ASSISTANT, + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hi")], + ), + ], + ), + backend=backend, + ) + session = await repo.get(session_id) + assert session is not None + summary = await repo.get_summary(session_id) + finally: + await repo.close() + + assert session.display_name == "greedy-squishing-hamming" + assert session.display_title == "greedy-squishing-hamming" + + assert summary is not None + assert summary.display_name == "greedy-squishing-hamming" + assert summary.display_title == "greedy-squishing-hamming" + + +async def test_display_name_does_not_override_a_real_title(tmp_path: Path) -> None: + """A real provider title still wins over the slug (title > display_name precedence).""" + backend = SQLiteBackend(db_path=tmp_path / "display-name-title-wins.db") + repo = SessionRepository(backend=backend) + try: + session_id = await ingest_session( + ParsedSession( + source_name=Provider.CLAUDE_CODE, + provider_session_id="titled-session", + title="Recover what was lost", + display_name="greedy-squishing-hamming", + messages=[ + ParsedMessage( + provider_message_id="m1", + role=Role.ASSISTANT, + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hi")], + ), + ], + ), + backend=backend, + ) + session = await repo.get(session_id) + finally: + await repo.close() + + assert session is not None + assert session.display_name == "greedy-squishing-hamming" + assert session.display_title == "Recover what was lost"