From 984105a942b744809a8b4db075f1d8287b219384 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 22 Oct 2026 21:52:28 -0400 Subject: [PATCH 1/3] Own current display references and snapshot field restoration --- src/agent_comms/display_references.py | 41 ++++++++--- src/agent_comms/errors.py | 6 ++ src/agent_comms/presentation.py | 6 +- src/agent_comms/registry_document.py | 96 ++++++++++++++++++-------- src/agent_comms/route_selection.py | 7 +- src/agent_comms/thread_presentation.py | 10 ++- src/agent_comms/ui_model/status.py | 5 ++ tests/test_sidebar_observation.py | 82 +++++++++++++++++++++- tests/test_thread_presentation.py | 12 +++- 9 files changed, 215 insertions(+), 50 deletions(-) diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 7925401011..5d2026172b 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -9,9 +9,13 @@ from dataclasses import dataclass, field from functools import cached_property import re +from typing import TYPE_CHECKING -from .declared_family import DeclaredFamily -from .thread_identity import ThreadIncarnation +from agent_comms.declared_family import DeclaredFamily +from agent_comms.thread_identity import ThreadIncarnation + +if TYPE_CHECKING: + from agent_comms.registry_document import RegistrySnapshot @dataclass(frozen=True) @@ -142,15 +146,6 @@ class DisplayReferences(RootedReferences): description = "" targets: Mapping[str, DisplayReference] = field(default_factory=dict) - @classmethod - def capture(cls, root, registry, channels) -> DisplayReferences: - targets = {name: ThreadDisplayReference(str(root), thread.incarnation) - for name, thread in registry.threads.items()} - targets.update((alias, targets[name]) for alias, name in registry.aliases.items() - if name in targets) - targets.update((name, ChannelDisplayReference(str(root), name)) for name in channels) - return cls(str(root), targets) - @classmethod def recorded(cls, root, source) -> DisplayReferences: registry = source.provenance @@ -188,3 +183,27 @@ def accepts_history(self, root): def contains(self, reference: DisplayReference) -> bool: return self.targets.get(reference.name) == reference + + +@dataclass(frozen=True) +class CurrentDisplayReferences(DisplayReferences): + """Current targets retain their registry cut, not another identity index. + + Recorded catalogs share name recognition but cannot resolve a live target. + """ + + registry: RegistrySnapshot = field(kw_only=True, repr=False, compare=False) + + @classmethod + def capture(cls, root, registry: RegistrySnapshot, channels) -> CurrentDisplayReferences: + targets = {name: ThreadDisplayReference(str(root), thread.incarnation) + for name, thread in registry.threads.items()} + targets.update((alias, targets[name]) for alias, name in registry.aliases.items() + if name in targets) + targets.update((name, ChannelDisplayReference(str(root), name)) for name in channels) + return cls(str(root), targets, registry=registry) + + def thread(self, incarnation: ThreadIncarnation) -> ThreadDisplayReference: + """Resolve an exact birth to its current name; a missing birth fails.""" + current = self.registry.by_lookup[incarnation.require_recorded().lookup] + return ThreadDisplayReference(self.root, current.incarnation) diff --git a/src/agent_comms/errors.py b/src/agent_comms/errors.py index eca99d2acc..8fc6f17619 100644 --- a/src/agent_comms/errors.py +++ b/src/agent_comms/errors.py @@ -22,6 +22,12 @@ class UnknownChannelError(ToolRefusedError): """A target is absent from the catalog and current thread tags.""" +class RouteChangedError(ValueError): + """The selected Comms route was replaced; reopen against the current one.""" + + reader_sentence = "On another Comms route; reopen this view" + + class RelationViolationError(ValueError): """A required relation between declarations cannot be proved.""" diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 11d844b896..f6aa312938 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -23,7 +23,7 @@ from .coordination_errors import DMPaintedBoundChanged, StaleRevision from .declared_family import DeclaredFamily from .display_order import ChannelSort, NameChannelSort -from .display_references import DisplayReferences +from .display_references import CurrentDisplayReferences from .external_transcript import ExternalTranscript from .field_codec import ( Differs, @@ -683,7 +683,7 @@ class CoordinationSnapshot: unread: Mapping[str, UnreadPresentation] channel_unread: Mapping[str, UnreadPresentation] last_sent: Mapping[str, float] - references: DisplayReferences + references: CurrentDisplayReferences channel_order: type[ChannelSort] = NameChannelSort thread_unread: Mapping[str, UnreadPresentation] = field(default_factory=dict) read_marker_notice: ReadNotice = field(default_factory=NoReadNotice) @@ -711,7 +711,7 @@ def capture( channel_unread={name: ExactUnread(count) for name, count in channel_unread.items()}, last_sent=sent, thread_unread={name: UnobservedUnread() for name in threads}, - references=DisplayReferences.capture(root, registry, declarations), + references=CurrentDisplayReferences.capture(root, registry, declarations), channel_order=catalog.list_order, read_marker_notice=read_marker_notice, ) diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index edab192a01..beca3d3e0f 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field, replace from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, Annotated +from typing import TYPE_CHECKING, Any, Literal, Annotated, Self from .child_process import NoProcess, ProcessIdentity @@ -198,16 +198,31 @@ def of(plan: FieldPlan) -> type[ThreadSlice]: return policy.value raise TypeError(f"{plan.name} declares no thread slice") - @staticmethod - def required[Document: type](document: Document) -> Document: + @classmethod + def required[Document: type](cls, document: Document) -> Document: """Refuse, at class definition, a document field without a slice kind.""" for plan in FieldCodec.field_plans(document): if plan.declared.init and plan.records: - ThreadSlice.of(plan) + cls.of(plan) return document -class OwnRow(ThreadSlice[dict]): +class SnapshotValue[Value](ThreadSlice[Value]): + """A declared registry field owns both editable and snapshot copies.""" + + @classmethod + @abstractmethod + def freeze(cls, value: Value) -> object: ... + + @staticmethod + def of(plan: FieldPlan) -> type[SnapshotValue]: + policy = ThreadSlice.of(plan) + if not issubclass(policy, SnapshotValue): + raise TypeError(f"{plan.name} declares no snapshot value policy") + return policy + + +class OwnRow(SnapshotValue[dict]): """The row under the thread's own name.""" @staticmethod @@ -215,9 +230,13 @@ def cut(raw: dict, canonical: str) -> dict: return {canonical: raw[canonical]} if canonical in raw else {} @classmethod - def copy(cls, value: dict) -> dict: + def copy(cls, value: Mapping) -> dict: return dict(value) + @classmethod + def freeze(cls, value: Mapping) -> Mapping: + return MappingProxyType(cls.copy(value)) + class AliasesOf(OwnRow): """The aliases that point at the thread; they also resolve its canonical name.""" @@ -243,7 +262,7 @@ def copy(cls, value: GenerationCounter) -> GenerationCounter: return GenerationCounter(value.counter, dict(value.generations)) -class WholeDocumentValue[Value](ThreadSlice[Value]): +class WholeDocumentValue[Value](SnapshotValue[Value]): """A scalar shared by the whole document and every one-thread view.""" @staticmethod @@ -254,6 +273,10 @@ def cut(raw: Value, canonical: str) -> Value: def copy(cls, value: Value) -> Value: return value + @classmethod + def freeze(cls, value: Value) -> Value: + return cls.copy(value) + class RootPurpose(DeclaredFamily, affix="RootPurpose"): """The registry's durable permission to mutate this root.""" @@ -718,16 +741,40 @@ def owner_identity(self) -> OwnerIdentity: return OwnerIdentity(self.thread.incarnation, self.owner_generation) +@SnapshotValue.required @dataclass(frozen=True, slots=True) class RegistrySnapshot(RegistryPresence[Mapping[str, ThreadStatus]], RegistryProvenance): - purpose: RootPurpose - threads: Mapping[str, Thread] - statuses: Mapping[str, ThreadStatus] - last_seen: Mapping[str, float] - aliases: Mapping[str, str] - owner_generations: Mapping[str, int] - _claimed_generations: Mapping[str, int] = field(repr=False) - by_lookup: Mapping[str, Thread] = field(repr=False, compare=False) + purpose: Annotated[RootPurpose, SlicePolicy(WholeDocumentValue)] + threads: Annotated[Mapping[str, Thread], SlicePolicy(OwnRow)] + statuses: Annotated[Mapping[str, ThreadStatus], SlicePolicy(OwnRow)] + last_seen: Annotated[Mapping[str, float], SlicePolicy(OwnRow)] + aliases: Annotated[Mapping[str, str], SlicePolicy(OwnRow)] + owner_generations: Annotated[Mapping[str, int], SlicePolicy(OwnRow)] + _claimed_generations: Annotated[Mapping[str, int], SlicePolicy(OwnRow)] = field(repr=False) + by_lookup: Mapping[str, Thread] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + """Own each copied value and derive the birth index from the frozen threads.""" + for plan in FieldCodec.field_plans(type(self)): + if plan.declared.init: + object.__setattr__( + self, plan.name, SnapshotValue.of(plan).freeze(getattr(self, plan.name)), + ) + indexed: dict[str, Thread] = {} + for thread in self.threads.values(): + lookup = thread.incarnation.require_recorded().lookup + if lookup in indexed: + raise RelationViolationError("Registry creation identities collide.") + indexed[lookup] = thread + object.__setattr__(self, "by_lookup", MappingProxyType(indexed)) + super(RegistrySnapshot, self).__post_init__() + + def __reduce__(self) -> tuple[type[Self], tuple[object, ...]]: + """Worker restoration preserves scalar policy and re-enters the constructor.""" + return type(self), tuple( + SnapshotValue.of(plan).copy(getattr(self, plan.name)) + for plan in FieldCodec.field_plans(type(self)) if plan.declared.init + ) def claim_generation(self, name: str) -> int: """The allocated input generation follows this registry's alias lineage.""" @@ -756,21 +803,14 @@ def idle_after(self, fence: FinishedTurnFence) -> tuple[Thread, ...]: @classmethod def capture(cls, document: RegistryDocument) -> RegistrySnapshot: """One live registry cut owns the index and all its source maps.""" - indexed: dict[str, Thread] = {} - for thread in document.threads.values(): - lookup = thread.incarnation.require_recorded().lookup - if lookup in indexed: - raise RelationViolationError("Registry creation identities collide.") - indexed[lookup] = thread return cls( purpose=document.purpose, - threads=MappingProxyType(dict(document.threads)), - statuses=MappingProxyType(dict(document.statuses)), - last_seen=MappingProxyType(dict(document.last_seen)), - aliases=MappingProxyType(dict(document.aliases)), - owner_generations=MappingProxyType(dict(document.owners.generations)), - _claimed_generations=MappingProxyType(dict(document.claims.generations)), - by_lookup=MappingProxyType(indexed), + threads=document.threads, + statuses=document.statuses, + last_seen=document.last_seen, + aliases=document.aliases, + owner_generations=document.owners.generations, + _claimed_generations=document.claims.generations, ) def observed_activity( diff --git a/src/agent_comms/route_selection.py b/src/agent_comms/route_selection.py index a99f1d95d7..69f5b702a4 100644 --- a/src/agent_comms/route_selection.py +++ b/src/agent_comms/route_selection.py @@ -14,13 +14,12 @@ from pathlib import Path from typing import TypeVar +from agent_comms import errors + from .active_route import CommsRoute, resolve_comms_route from .launch_root import LaunchRoot -class RouteChanged(ValueError): - """The selected Comms route was replaced; reopen against the current one.""" - T = TypeVar("T") @@ -49,7 +48,7 @@ def capture(cls, source: str | Path | None = None) -> RouteSelection: route = resolve_comms_route() selection = cls(route, route.observe_root()) if source is not None and Path(source).expanduser().resolve() != selection.root: - raise RouteChanged("Comms route changed; reopen this view") + raise errors.RouteChangedError(errors.RouteChangedError.reader_sentence) return selection @classmethod diff --git a/src/agent_comms/thread_presentation.py b/src/agent_comms/thread_presentation.py index 8987ff88bc..06871e45a6 100644 --- a/src/agent_comms/thread_presentation.py +++ b/src/agent_comms/thread_presentation.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, Self from agent_comms.coordination_errors import StaleRevision -from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.errors import RelationViolationError, RouteChangedError, UnregisteredThreadError from .declared_family import DeclaredFamily from .thread_identity import OwnerIdentity @@ -125,6 +125,14 @@ def presentation(self, title: str) -> ThreadPresentation: return ThreadPresentation(title, "○", "Thread is no longer available", status=self) +@dataclass(frozen=True, slots=True) +class OtherRouteThread(ThreadPresentationStatus): + """The view thread lives on a Comms route that is not selected.""" + + def presentation(self, title: str) -> ThreadPresentation: + return ThreadPresentation(title, "↗", RouteChangedError.reader_sentence, status=self) + + class ThreadOwnerBinding(DeclaredFamily, affix="ThreadOwnerBinding"): """A projection of the registry lease, never an alternative owner store.""" diff --git a/src/agent_comms/ui_model/status.py b/src/agent_comms/ui_model/status.py index 1f6695d957..a1340030ed 100644 --- a/src/agent_comms/ui_model/status.py +++ b/src/agent_comms/ui_model/status.py @@ -13,6 +13,7 @@ from agent_comms.thread_presentation import ( IdleThreadPresentationStatus, + OtherRouteThread, SessionDisconnected, StatusPending, ThreadRetired, @@ -84,6 +85,10 @@ def pending(cls, name: str) -> ThreadStatusModel: def retired(cls, name: str) -> ThreadStatusModel: return cls.known(name, ThreadRetired().presentation(name)) + @classmethod + def other_route(cls, name: str) -> ThreadStatusModel: + return cls.known(name, OtherRouteThread().presentation(name)) + @classmethod def unreadable(cls, name: str, error: UnreadableHistory) -> ThreadStatusModel: return cls.known(name, ThreadPresentation( diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 13fddb768f..b6f8ce0d32 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -1,7 +1,14 @@ """Sidebar states answer the same questions before and after a real read.""" +import pickle +from collections.abc import Mapping +from dataclasses import fields, replace + import pytest +from agent_comms.errors import RelationViolationError +from agent_comms.registry_document import HistoricalRootPurpose +from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus from agent_comms.ui_model.sidebar import SidebarModel from agent_comms.ui_model.unread import ExactUnread, UnobservedUnread @@ -17,7 +24,8 @@ def test_sidebar_apply_and_failure_publish_their_original_answers(comms): assert model.observation.require_snapshot() is snapshot assert model.observation.channel_order() is snapshot.channel_order assert model.observation.channel_unread("#any") == ExactUnread(0) - assert model.observation.bus_unread("missing") == ExactUnread(0) + with pytest.raises(KeyError): + model.observation.bus_unread("missing") assert set(model.channels.rows) == set(snapshot.channels) failure = ValueError("original sidebar read failure") model.clear(failure) @@ -26,3 +34,75 @@ def test_sidebar_apply_and_failure_publish_their_original_answers(comms): with pytest.raises(ValueError) as caught: model.observation.require_snapshot() assert caught.value is failure + + +@pytest.fixture +def declare_reference(comms): + def declare(name="reference", status=StoppedThreadStatus()): + return comms.registry.declare(comms.registry.new_thread( + name=name, tags=frozenset(), worktree=str(comms.root.parent), + ), status=status) + return declare + + +def test_registry_snapshot_constructor_and_pickle_own_immutable_maps(comms, declare_reference): + original = declare_reference() + captured = comms.registry.snapshot() + supplied = dict(captured.threads) + snapshot = replace(captured, threads=supplied) + supplied.clear() + for restored in (snapshot, pickle.loads(pickle.dumps(snapshot, protocol=5))): + assert restored.purpose == captured.purpose + assert restored.threads[original.name] == original + assert restored.by_lookup[original.incarnation.require_recorded().lookup] == original + for declaration in fields(restored): + value = getattr(restored, declaration.name) + if isinstance(value, Mapping): + with pytest.raises(TypeError): + value["unowned"] = None + + +def test_snapshot_restoration_preserves_historical_write_refusal(comms): + archived = replace(comms.registry.snapshot(), purpose=HistoricalRootPurpose()) + restored = pickle.loads(pickle.dumps(archived, protocol=5)) + with pytest.raises(RelationViolationError, match="read-only"): + restored.purpose.require_write() + + +def test_snapshot_constructor_retains_namespace_validation_and_derives_birth_index( + comms, declare_reference, +): + original = declare_reference() + captured = comms.registry.snapshot() + with pytest.raises(RelationViolationError, match="name differs"): + replace(captured, threads={"misnamed": original}) + with pytest.raises(RelationViolationError, match="alias"): + replace(captured, aliases={"unowned": "absent"}) + retired = replace(captured, threads={}) + assert not retired.by_lookup + assert captured.by_lookup[original.incarnation.lookup] == original + + +def test_current_reference_resolves_original_birth_after_registry_rename(comms, declare_reference): + original = declare_reference(status=RunningThreadStatus()) + comms.registry.rename(original.name, "renamed") + snapshot = comms.views.viewer_snapshot(str(comms.root.parent)) + references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references + current = references.thread(original.incarnation) + assert current.name == "renamed" + assert current.incarnation == original.incarnation + assert current.current_in(references) + + +def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, declare_reference): + original = declare_reference() + before = comms.views.viewer_snapshot(str(comms.root.parent)).references + comms.threads.delete(original.name) + recreated = declare_reference(original.name) + snapshot = comms.views.viewer_snapshot(str(comms.root.parent)) + references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references + assert recreated.incarnation != original.incarnation + assert references.thread(recreated.incarnation).incarnation == recreated.incarnation + assert before.thread(original.incarnation).incarnation == original.incarnation + with pytest.raises(KeyError): + references.thread(original.incarnation) diff --git a/tests/test_thread_presentation.py b/tests/test_thread_presentation.py index 4c7ef7f6cf..a52a53a0bb 100644 --- a/tests/test_thread_presentation.py +++ b/tests/test_thread_presentation.py @@ -4,11 +4,11 @@ import pytest -from agent_comms.errors import UnregisteredThreadError +from agent_comms.errors import RouteChangedError, UnregisteredThreadError from agent_comms.field_codec import FieldCodec from agent_comms.notification_progress import SendingProgress from agent_comms.thread_presentation import ( - IdleThreadPresentationStatus, NoThreadPresentation, SessionDisconnected, + IdleThreadPresentationStatus, NoThreadPresentation, OtherRouteThread, SessionDisconnected, StatusPending, ThreadPresentationStatus, WorkingThreadPresentationStatus, ) from agent_comms.ui_model.delivery import InputDelivery @@ -52,6 +52,14 @@ def test_missing_presentation_owns_source_absence_and_retired_status(comms): presentations.require("missing") +def test_other_route_status_presents_the_route_owners_explanation(): + row = ThreadStatusModel.other_route("x") + assert row.name == "x" + assert isinstance(row.status, OtherRouteThread) + assert row.lines == row.summary == (RouteChangedError.reader_sentence,) + assert row.presentation.title == "x" + + @pytest.mark.parametrize("original", [ IdleThreadPresentationStatus(), WorkingThreadPresentationStatus(), SendingProgress(23), SessionDisconnected("original disconnect"), From 7f0b4734605d4270fc5be8ea78f8f71416a546df Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 22 Oct 2026 22:06:02 -0400 Subject: [PATCH 2/3] Derive current target views and keep historical factories separate --- src/agent_comms/display_references.py | 34 +++++++++++++++++---------- src/agent_comms/presentation.py | 2 +- tests/test_sidebar_observation.py | 27 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 5d2026172b..65c94ba1f2 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -6,10 +6,11 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from functools import cached_property import re -from typing import TYPE_CHECKING +from types import MappingProxyType +from typing import TYPE_CHECKING, Self from agent_comms.declared_family import DeclaredFamily from agent_comms.thread_identity import ThreadIncarnation @@ -146,14 +147,14 @@ class DisplayReferences(RootedReferences): description = "" targets: Mapping[str, DisplayReference] = field(default_factory=dict) - @classmethod - def recorded(cls, root, source) -> DisplayReferences: + @staticmethod + def recorded(root, source) -> DisplayReferences: registry = source.provenance targets = {name: HistoricalDisplayReference(str(root), thread.incarnation, source.key) for name, thread in registry.threads.items()} targets.update((alias, targets[name]) for alias, name in registry.aliases.items() if name in targets) - return cls(str(root), targets) + return DisplayReferences(str(root), targets) def for_source(self, source) -> DisplayReferences: if source is None: @@ -192,16 +193,23 @@ class CurrentDisplayReferences(DisplayReferences): Recorded catalogs share name recognition but cannot resolve a live target. """ - registry: RegistrySnapshot = field(kw_only=True, repr=False, compare=False) + targets: Mapping[str, DisplayReference] = field(init=False) + registry: RegistrySnapshot = field(repr=False, compare=False) + channels: tuple[str, ...] = () - @classmethod - def capture(cls, root, registry: RegistrySnapshot, channels) -> CurrentDisplayReferences: - targets = {name: ThreadDisplayReference(str(root), thread.incarnation) - for name, thread in registry.threads.items()} - targets.update((alias, targets[name]) for alias, name in registry.aliases.items() + def __post_init__(self) -> None: + targets = {name: ThreadDisplayReference(self.root, thread.incarnation) + for name, thread in self.registry.threads.items()} + targets.update((alias, targets[name]) for alias, name in self.registry.aliases.items() if name in targets) - targets.update((name, ChannelDisplayReference(str(root), name)) for name in channels) - return cls(str(root), targets, registry=registry) + targets.update((name, ChannelDisplayReference(self.root, name)) for name in self.channels) + object.__setattr__(self, "targets", MappingProxyType(targets)) + + def __reduce__(self) -> tuple[type[Self], tuple[object, ...]]: + """Restore declared inputs; rebuild the target view from their owners.""" + return type(self), tuple( + getattr(self, declaration.name) for declaration in fields(self) if declaration.init + ) def thread(self, incarnation: ThreadIncarnation) -> ThreadDisplayReference: """Resolve an exact birth to its current name; a missing birth fails.""" diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index f6aa312938..e2d350df85 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -711,7 +711,7 @@ def capture( channel_unread={name: ExactUnread(count) for name, count in channel_unread.items()}, last_sent=sent, thread_unread={name: UnobservedUnread() for name in threads}, - references=CurrentDisplayReferences.capture(root, registry, declarations), + references=CurrentDisplayReferences(str(root), registry, tuple(declarations)), channel_order=catalog.list_order, read_marker_notice=read_marker_notice, ) diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index b6f8ce0d32..f32507d943 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -7,7 +7,10 @@ import pytest from agent_comms.errors import RelationViolationError +from agent_comms.display_references import CurrentDisplayReferences, HistoricalDisplayReference +from agent_comms.historical_views import HistorySource from agent_comms.registry_document import HistoricalRootPurpose +from agent_comms.registry_provenance import RegistryProvenance from agent_comms.thread_status import RunningThreadStatus, StoppedThreadStatus from agent_comms.ui_model.sidebar import SidebarModel from agent_comms.ui_model.unread import ExactUnread, UnobservedUnread @@ -106,3 +109,27 @@ def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, decl assert before.thread(original.incarnation).incarnation == original.incarnation with pytest.raises(KeyError): references.thread(original.incarnation) + + +def test_current_reference_membership_and_lookup_share_the_retained_registry(comms, declare_reference): + original = declare_reference() + references = CurrentDisplayReferences(str(comms.root), registry=comms.registry.snapshot()) + for restored in (references, pickle.loads(pickle.dumps(references, protocol=5))): + reference = restored.thread(original.incarnation) + assert reference.current_in(restored) + assert restored.find(f"@{original.name}")[0].reference == reference + with pytest.raises(TypeError): + restored.targets["unowned"] = reference + + +def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): + original = declare_reference() + references = comms.views.viewer_snapshot(str(comms.root.parent)).references + source = HistorySource.capture_original(comms.root, RegistryProvenance.capture(references.registry)) + restored = pickle.loads(pickle.dumps(references, protocol=5)) + recorded = restored.for_source(source) + assert not isinstance(recorded, CurrentDisplayReferences) + reference = recorded.find(f"@{original.name}")[0].reference + assert isinstance(reference, HistoricalDisplayReference) + assert reference.source == source.key + assert reference.incarnation == original.incarnation From 71616645d9677e9153e6bf5c7c4dcffadd2e9f15 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 22 Oct 2026 22:10:58 -0400 Subject: [PATCH 3/3] Use registry-owned canonical and alias membership for references --- src/agent_comms/display_references.py | 8 ++------ tests/test_sidebar_observation.py | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 65c94ba1f2..00f321c359 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -151,9 +151,7 @@ class DisplayReferences(RootedReferences): def recorded(root, source) -> DisplayReferences: registry = source.provenance targets = {name: HistoricalDisplayReference(str(root), thread.incarnation, source.key) - for name, thread in registry.threads.items()} - targets.update((alias, targets[name]) for alias, name in registry.aliases.items() - if name in targets) + for name, thread in registry.named_members()} return DisplayReferences(str(root), targets) def for_source(self, source) -> DisplayReferences: @@ -199,9 +197,7 @@ class CurrentDisplayReferences(DisplayReferences): def __post_init__(self) -> None: targets = {name: ThreadDisplayReference(self.root, thread.incarnation) - for name, thread in self.registry.threads.items()} - targets.update((alias, targets[name]) for alias, name in self.registry.aliases.items() - if name in targets) + for name, thread in self.registry.named_members()} targets.update((name, ChannelDisplayReference(self.root, name)) for name in self.channels) object.__setattr__(self, "targets", MappingProxyType(targets)) diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index f32507d943..9c7040bcfd 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -95,6 +95,7 @@ def test_current_reference_resolves_original_birth_after_registry_rename(comms, assert current.name == "renamed" assert current.incarnation == original.incarnation assert current.current_in(references) + assert references.find(f"@{original.name}")[0].reference == current def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, declare_reference):