diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 2f4b1ce036..45dee1f3e9 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -191,7 +191,7 @@ def require_session_exit(self, session_files: tuple[Path, ...]) -> None: if not self.issued_for(self._store): raise RecoveryBlocked("native exit observation requires acquired owner loss") session_dir = ( - self._store.session.path.parent / "native-sessions" / self.release.incarnation().lookup + self._store.session.path.parent / "native-sessions" / self.release.incarnation.lookup ) proc = Path("/proc") if not proc.is_dir(): diff --git a/src/agent_comms/bus.py b/src/agent_comms/bus.py index d905a17a2c..85b58644ce 100644 --- a/src/agent_comms/bus.py +++ b/src/agent_comms/bus.py @@ -49,6 +49,7 @@ ResourceClaim, ) from agent_comms.errors import CommittedMessageError, RelationViolationError, UnclaimedError +from agent_comms.coordination_errors import CoordinationReadUnavailable from agent_comms.field_codec import ClaimId, FieldCodec, MessageId, ThreadLookup from agent_comms.locked_store import BusFile, FileStore, SQLiteReader, StoreCommittedError from agent_comms.message_reference import MessageReference @@ -130,13 +131,18 @@ def counts(self) -> dict[str, int]: def advanced(self, source: BusReader, selection: DisplayUnreadSelection) -> DisplayUnread: queries = selection.queries() through = source.revision() - if through == self.through and tuple(self.counted) == queries: - return self earlier = {query.counting: query for query in self.counted} - kept = {query: query for query in queries if query in self.counted} - moved = {query: (earlier[query.counting], parts) for query in queries - if query not in kept and query.counting in earlier - and (parts := query.moved(earlier[query.counting]))} + reusable = {query: earlier[query.counting] for query in queries + if query.counting in earlier + and query.same_membership(earlier[query.counting])} + kept = {query: previous for query, previous in reusable.items() + if query == previous} + if through == self.through and len(kept) == len(queries): + if tuple(self.counted) == queries: + return self + return DisplayUnread(through, {query: self.counted[kept[query]] for query in queries}) + moved = {query: (previous, parts) for query, previous in reusable.items() + if query not in kept and (parts := query.moved(previous))} progressed = (*kept, *(previous for previous, _ in moved.values())) appended = source.count_unread( progressed if through != self.through else (), through=through, after=self.through) @@ -174,6 +180,10 @@ def reading(self) -> Iterator[BusSnapshot]: with self.observing() as db: yield BusSnapshot.from_connection(db) + @abstractmethod + def captured_reading(self) -> AbstractContextManager[BusSnapshot]: + """Retain a cut independently of ordinary reads on the acquiring thread.""" + def revision(self) -> int: with self.observing() as db: return BusRow.last_seq(db) @@ -186,10 +196,10 @@ def target_activity(self, predicate: str, parameters: tuple[object, ...]) -> Cha with self.observing() as db: return BusActivityIndex.aggregate(db, predicate, parameters) - def participant_activity(self, names: frozenset[str]) -> ChannelActivity: - marks = ','.join('?' for _ in names) + def participant_activity(self, predicate: str, + parameters: tuple[object, ...]) -> ChannelActivity: with self.observing() as db: - return BusDisplayIndex.aggregate(db, f'participant IN ({marks})', tuple(names)) + return BusDisplayIndex.aggregate(db, predicate, parameters) def claim_projection(self) -> ClaimProjection: with self.observing() as db: @@ -253,9 +263,24 @@ def keyed_messages(self, publication_key: str) -> tuple[BusRecord, ...]: def page(self, before: int, limit: int) -> tuple[BusRecord, ...]: with self.observing() as db: - return BusCapability.at_sequences(db, tuple(row.seq for row in BusRow.select( - db, where="{seq} < ?", parameters=(before,), - order_by=("seq",), descending=True, limit=limit))) + return BusCapability.at_sequences( + db, tuple(row.seq for row in self._page_rows(db, before, limit)), + ) + + def input_page(self, before: int, limit: int) -> tuple[MessageInput, ...]: + """The same global sequence window, without attachment/report/answer acquisition.""" + with self.observing() as db: + return tuple( + MessageInput.read_from(db, row.seq) + for row in self._page_rows(db, before, limit) + ) + + @staticmethod + def _page_rows(db: sqlite3.Connection, before: int, limit: int) -> list[BusRow]: + return BusRow.select( + db, where="{seq} < ?", parameters=(before,), + order_by=("seq",), descending=True, limit=limit, + ) def message_history(self) -> Iterator[Message]: """Public bulk export, paged within one committed SQLite snapshot.""" @@ -288,22 +313,22 @@ def scoped_page( upper: int | None = None, descending: bool = False, limit: int = 100, kinds: tuple[type[MessageKind], ...] = (), ) -> tuple[Message, ...]: - """A captured display scope narrows the addressed table before decoding.""" + """A captured display scope reads public message capabilities, not delivery payloads.""" with self.observing() as db: upper = BusRow.last_seq(db) + 1 if upper is None else upper order = "DESC" if descending else "ASC" members = "" if kinds: members = MessageInput.render(' AND {type} IN (') + ','.join('?' for _ in kinds) + ')' - rows = db.execute( + return tuple(Message.selected( + db, f'SELECT seq FROM "{Addressed.table.declared_name}" ' f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' f'WHERE ({predicate}) AND seq > ? AND seq < ?{members} ' f'ORDER BY seq {order} LIMIT ?', (*parameters, lower, upper, *(kind.declared_name for kind in kinds), limit), - ) - headers = (BusRow.require(db, seq=FieldCodec.decode(int, row[0])) for row in rows) - return BusCapability.at_sequences(db, tuple(header.seq for header in headers)) + descending=descending, + )) def latest_conversations( self, predicate: str, parameters: tuple[object, ...], through: int, @@ -469,7 +494,8 @@ def count_unread( counts[query] = FieldCodec.decode(int, db.execute( f'WITH {ReadCursor.sql("?")}, requests AS (' f'SELECT \'viewer\' AS actor,* FROM "{Addressed.table.declared_name}" ' - f'JOIN "{MessageInput.table.declared_name}" USING(seq) WHERE seq > ? AND seq <= ? ' + f'JOIN "{MessageInput.table.declared_name}" USING(seq) ' + f'WHERE seq > ? AND seq <= ? ' f'AND ({query.predicate}) AND sender NOT IN (SELECT value FROM json_each(?))), ' f'{ReadCursor.joined_sql} SELECT count(*) FROM cursors WHERE {ReadCursor.unread_sql}', (CursorRow.binding(query.cursors), max(after, query.lower_bound), through, @@ -498,6 +524,14 @@ def observing(self) -> Iterator[sqlite3.Connection]: with self.reader_snapshot() as db: yield db + @contextmanager + def captured_reading(self) -> Iterator[BusSnapshot]: + # Unlike a nested ordinary read, a presentation outlives this worker's + # next request. Its transaction must never enter the thread-local pool. + with CoordinationReadUnavailable.checking(): + with closing(self.read_snapshot(self.path, check_same_thread=False)) as db: + yield BusSnapshot.from_connection(db) + class Bus(FileStore, StoredBusReader, ReconciledFile, WalStore, CarriedWriter, BusFile, Watched): """One SQLite transaction owns the sequence and all of a record's fields.""" @@ -655,3 +689,7 @@ def observing(self) -> Iterator[sqlite3.Connection]: @contextmanager def reading(self) -> Iterator[BusSnapshot]: yield self + + @contextmanager + def captured_reading(self) -> Iterator[BusSnapshot]: + yield self diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index cad7959829..b95822994e 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -16,6 +16,7 @@ from .repo import Checkout from .response_policy import DirectPolicy, MentionedPolicy from .thread_identity import ThreadIncarnation +from .thread_name import ThreadName from .typed_table import sql_literal from .wake_policy import BoundedTriageWake, FullWake, MessageAudience @@ -419,9 +420,7 @@ def bindings[C: CliCommand]( def validate(self, message: Message) -> None: if message.sender == self.name and message.type.conversation_message: raise RelationViolationError(f"Thread {message.sender!r} cannot message itself.") - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not set(self.name) <= allowed: - raise ValueError(f"Message target {self.name!r} is not a thread name or #channel.") + ThreadName.require(self.name) def wake_policy(self, recipient: FrozenRecipient, mentions: frozenset[str]) -> FullWake: return FullWake() @@ -483,10 +482,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "name", self.incarnation.name) def selected_name(self, snapshot: RegistryProvenance) -> str: - if not self.incarnation.current(snapshot): - raise UnregisteredThreadError( - f"Original DM peer {self.name!r} is no longer registered.") - return super().selected_name(snapshot) + return self.incarnation.require_current(snapshot).name def require_publication(self, message: Message, snapshot: RegistryProvenance, require_channel: Callable[[str], Channel]) -> None: diff --git a/src/agent_comms/check_root.py b/src/agent_comms/check_root.py index 7070dc907a..a9ce91a426 100644 --- a/src/agent_comms/check_root.py +++ b/src/agent_comms/check_root.py @@ -24,7 +24,7 @@ from .launch_runtime import ThreadLaunch from .native_entries import ImportedSessionWriter, NativeEntry from .native_session_files import CheckSession -from .read_basis import MemberActivityDisplayScope +from .read_basis import NamedActivityDisplayScope from .registry_document import RosterVisibility from .session_file import RegistrySessionLocation from .thread_storage import StorageRoots @@ -94,11 +94,11 @@ def _populate(self, copied: Comms) -> None: targets = frozenset( target for name in names for target in catalog.targets_for(snapshot.require(name).tags) ) - scope = MemberActivityDisplayScope("#check", targets, participant_names=selected | aliases) + scope = NamedActivityDisplayScope("#check", targets, participants=selected | aliases) with self.source.bus.bus.reading() as source: - messages = scope.history(source, limit=self.max_records) + history = scope.history(source, limit=self.max_records) copied.registry.store.prepare_native_root() - Bus.install(Bus.path_in(copied.root), history=messages) + Bus.install(Bus.path_in(copied.root), history=history) copied.channels.catalog.replace(catalog) declarations = {} for name in names: @@ -131,7 +131,7 @@ def _populate(self, copied: Comms) -> None: edit.document.advance_claim(name) edit.document.owners.advance(name) edit.commit() - if messages: + if history: # Imported history is displayable, not fresh input for this root. # Position acceptance itself; human read markers own a different fact. privacy = copied.registry.store.privacy() @@ -141,7 +141,7 @@ def _populate(self, copied: Comms) -> None: AcceptanceFrontier( wire_root_id=privacy.namespace, recipient_lookup=thread.incarnation.require_recorded().lookup, - through_seq=messages[-1].seq, + through_seq=history[-1].seq, ).advance(db) def _session(self, original: Thread, root: Path, slot: str, project: Path) -> str: diff --git a/src/agent_comms/display_references.py b/src/agent_comms/display_references.py index 43ea9875b7..b0be1c8e42 100644 --- a/src/agent_comms/display_references.py +++ b/src/agent_comms/display_references.py @@ -158,8 +158,8 @@ class DisplayReferences(RootedReferences): @staticmethod def recorded(root, source) -> DisplayReferences: registry = source.provenance - targets = {name: HistoricalDisplayReference(str(root), thread.incarnation, source.key) - for name, thread in registry.named_members()} + targets = {name: HistoricalDisplayReference(str(root), incarnation, source.key) + for name, incarnation in registry.named_incarnations()} return DisplayReferences(str(root), targets) def for_source(self, source) -> DisplayReferences: @@ -209,7 +209,7 @@ class CurrentDisplayReferences(SnapshotRestored, DisplayReferences): @classmethod def from_registry(cls, root: str, registry: RegistrySnapshot, channels: tuple[str, ...] = ()) -> Self: - return cls(root, {name: thread.incarnation for name, thread in registry.named_members()}, channels) + return cls(root, dict(registry.named_incarnations()), channels) def __post_init__(self) -> None: """The target and lookup views of the one stored member map.""" diff --git a/src/agent_comms/field_codec.py b/src/agent_comms/field_codec.py index 012756c134..5304eba144 100644 --- a/src/agent_comms/field_codec.py +++ b/src/agent_comms/field_codec.py @@ -709,8 +709,8 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return (self.key,) - def encoded(self, value, *, key=None): - return {key or self.key: value} + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: + return {key: value} def schema_fields(self): annotation, declared = self.annotation, self.declared @@ -840,7 +840,7 @@ def contribute(self, fields: list[FieldPlan]) -> tuple[str, ...]: def wire_keys(self) -> tuple[str, ...]: return () - def encoded(self, value: object, *, key: str | None = None) -> dict[str, object]: + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: return {} def decoded(self, data: object) -> dict[str, object]: @@ -901,7 +901,9 @@ def contribute(self, fields): def wire_keys(self) -> tuple[str, ...]: return tuple(entry.key for entry in self.record.fields) - def encoded(self, value, *, key=None): + def encoded(self, value: JsonValue, *, key: str) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise MalformedRecord("An inline record must encode a JSON object.") return value def captured(self, data: object) -> dict[str, object]: @@ -2546,7 +2548,7 @@ def _tag(value: object) -> dict[str, str]: return RecordTag.of(value) @classmethod - def encode_fields(cls, value: object) -> dict[str, object]: + def encode_fields(cls, value: object) -> dict[str, JsonValue]: """Encode declared fields, except ``wire_exclude`` ones, including inside a custom wire representation.""" return _member_decoder(type(value)).encode(value) @@ -2559,15 +2561,37 @@ def decode_bound(cls, target: TypeForm[T], data: object, **context: object) -> T def decode_record( cls, target: type[T], data: object, *, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - """Decode declared fields, with nominal narrowing supplied by their contextual owner.""" + """Continue a boundary's record decode, retaining fields it already decoded.""" decoder = _RecordDecoder(target) for name, narrowed in field_types.items(): annotation, _ = cls.representation(cls.field_plan(target, name).annotation) if not issubclass(narrowed, annotation): raise TypeError(f"{target.__name__}.{name} cannot widen its declared type") return cast(T, decoder.decode_selected( - data, {}, field_types=field_types)) + data, {}, field_types=field_types, decoded_fields=decoded_fields)) + + @classmethod + def recorded_fields(cls, record: object) -> dict[str, object]: + """Already-decoded values supplied by this record's stored-field declarations.""" + return {entry.name: entry.current(record) + for entry in cls.field_plans(type(record)) if entry.records} + + @classmethod + def project_record( + cls, target: type[T], data: object, *, source: type, + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), + ) -> T: + """Acquire a declared record projection without decoding unrelated source fields.""" + raw = _RecordDecoder(source).checked(data) + projected: dict[str, object] = {} + for entry in cls._record_plan(target).bindings: + original = cls.field_plan(source, entry.name) + captured = original.captured(raw) + if entry.name in captured: + projected[entry.key] = captured[entry.name] + return cls.decode_record(target, projected, decoded_fields=decoded_fields) @classmethod def argument_fields(cls, target: type) -> tuple[FieldPlan, ...]: @@ -2708,7 +2732,7 @@ def _project_structure(cls, value: object, view: type[ViewExclusion], annotation @classmethod def _project_record(cls, value: object, view: type[ViewExclusion]) -> JsonValue: """A record's view: its tag, its view's fields and the properties the view owns.""" - result = cls._tag(value) + result: dict[str, JsonValue] = {**cls._tag(value)} result.update(cls._record_plan(type(value)).encoded( value, view, lambda entry, current: cls.project(current, view, entry.annotation) )) @@ -2739,8 +2763,11 @@ def required_wire_keys(self) -> frozenset[str]: def record_fields(self) -> tuple[tuple[Field[object], str], ...]: return tuple((plan.declared, plan.key) for plan in self.bindings) - def encoded(self, value, view, encode_value) -> dict[str, object]: - result: dict[str, object] = {} + def encoded( + self, value: object, view: str, + encode_value: Callable[[FieldPlan, object], JsonValue], + ) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} for entry in self.bindings: current = entry.current(value) if not entry.excluded_in(view) and not entry.omitted( @@ -3586,7 +3613,7 @@ def fits_wire(self, data: object) -> TypeGuard[dict[str, object]]: plan = self.plan return plan.required_wire_keys <= data.keys() <= plan.accepted_keys - def encode(self, value: object) -> dict[str, object]: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise MalformedRecord(f"Expected {self.target.__name__} record") result = self.plan.encoded( @@ -3645,23 +3672,34 @@ def decode_selected( self, data: object, selections: Mapping[tuple[type, str], object], *, context: Mapping[str, object] = {}, field_types: Mapping[str, type[object]] = types.MappingProxyType({}), + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: - return self.decode_entries(data, self.plan.bindings, selections, context, field_types) + return self.decode_entries( + data, self.plan.bindings, selections, context, field_types, decoded_fields, + ) def decode_entries( self, data: object, entries: tuple[FieldPlan, ...], selections: Mapping[tuple[type, str], object], context: Mapping[str, object], field_types: Mapping[str, type[object]], + decoded_fields: Mapping[str, object] = types.MappingProxyType({}), ) -> T: if not isinstance(data, dict): raise MalformedRecord("Expected a record object; fix: supply a JSON object.") - problems = self.missing_fields(data) + supplied = { + key for entry in entries if entry.name in decoded_fields for key in entry.wire_keys + } + problems = { + key: issue for key, issue in self.missing_fields(data).items() if key not in supplied + } unknown = list(self.unknown_fields(data)) target = self.target - values = {} + values = dict(decoded_fields) for entry in entries: key = (target, entry.name) try: + if entry.name in decoded_fields: + continue if entry.name in field_types: narrowed = compile_decoder(field_types[entry.name]) values.update(entry.decoded_using( @@ -3749,14 +3787,14 @@ def decode(self, data: object) -> BaseModel: with MalformedRecord.refusing(f"Invalid {self.target.__name__}"): return self.target.model_validate(data) - def encode(self, value: object) -> object: + def encode(self, value: object) -> dict[str, JsonValue]: if not isinstance(value, self.target): raise TypeError(f"Expected {self.target.__name__}, received {type(value).__name__}") return value.model_dump(mode="json", by_alias=True, exclude_none=True) @lru_cache(maxsize=None) -def _member_decoder(target: object) -> ValueDecoder[object]: +def _member_decoder(target: object) -> _RecordDecoder[object] | _PydanticDecoder: """A concrete record, enum or scalar, after any family tag is removed.""" if isinstance(target, type) and is_dataclass(target): return _RecordDecoder(target) diff --git a/src/agent_comms/historical_views.py b/src/agent_comms/historical_views.py index fc369c5358..dcd8fc8dc4 100644 --- a/src/agent_comms/historical_views.py +++ b/src/agent_comms/historical_views.py @@ -76,12 +76,24 @@ def viewer(self) -> DisplayViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: """Bind the query to the bus owner's snapshot and read that exact source.""" - with presentation.bus.snapshot() as (basis, source): + with presentation.snapshot(self.viewer) as (basis, source): request = self.page_request(self.capture(basis.registry), bounds) return request.read_opened(source) + @contextmanager + def reading(self, presentation: BusPresentation) -> Iterator[Callable[[PageBounds], MessagePage]]: + """Borrow one cut for the first tail, subsequent pages and scroll-back. + + The caller retains this lifetime only while its conversation revision is + unchanged, and retires it off the UI thread when that source is replaced. + """ + with presentation.reading(self.viewer) as captured: + history = captured.bus.history.captured() + yield partial(history.checked_page, self, captured) + def page(self, presentation: BusPresentation, bounds: PageBounds = NEWEST_PAGE) -> MessagePage: - return presentation.bus.history.integrated_page(self, presentation, bounds) + with self.reading(presentation) as read: + return read(bounds) @abstractmethod def capture(self, snapshot: RegistryProvenance) -> MessageDisplayScope: @@ -238,19 +250,16 @@ def viewer(self) -> NamedViewer: def live_page(self, presentation: BusPresentation, bounds: PageBounds) -> MessagePage: bus = presentation.bus - snapshot = presentation.registry.snapshot() - peer = self.second.selected_name(snapshot) - missing = peer not in snapshot.threads - if missing and bus.history.threads(peer): - return MessagePage((), False, False, NoDisplaySelection()) - return DMDisplayBasis.fetch_page( - bus, - presentation.registry, - bus.bus.path.parent, - snapshot.require(self.first), - self.second, - worktree=self.worktree, bounds=bounds, - ) + with presentation.snapshot(self.viewer) as (basis, source): + snapshot = basis.registry + peer = self.second.selected_name(snapshot) + missing = peer not in snapshot.threads + if missing and bus.history.threads(peer): + return MessagePage((), False, False, NoDisplaySelection()) + return DMDisplayBasis.fetch_captured( + bus, presentation.registry, bus.bus.path.parent, + self.second, basis, source, worktree=self.worktree, bounds=bounds, + ) @dataclass(frozen=True, slots=True) @@ -470,20 +479,37 @@ def sources(self) -> tuple[HistorySource, ...]: with self.read_sources() as sources: return sources + def captured(self) -> CapturedHistoryArchive: + """Retain the catalogue and its revision from the same original descriptor.""" + document = self._capture_unlocked() + return CapturedHistoryArchive( + self.path, self.read_copied(document), document.revision, + ) + + @property + def history_revision(self) -> FileObservation: + return self.captured().catalogue_revision + + @contextmanager + def reading(self) -> Iterator[CapturedHistoryArchive]: + """Validate a page's original catalogue before and after using it.""" + captured = self.captured() + self._require_revision(captured.catalogue_revision) + yield captured + self._require_revision(captured.catalogue_revision) + @contextmanager def read_sources(self) -> Iterator[tuple[HistorySource, ...]]: - """Decode one read's catalogue; reject changes without decoding it again.""" - captured = self._capture_unlocked() - sources = self.read_copied(captured) - self._require_revision(captured.revision) - yield sources - self._require_revision(captured.revision) + with self.reading() as captured: + yield captured.sources() def _require_revision(self, revision: FileObservation) -> None: if FileRevision.capture(self.path) != revision: raise HistoricalSourceChanged("Historical catalogue changed; refresh history") - def attach(self, source_root: Path, *, source_read: HistorySource | None = None) -> HistorySource: + def attach( + self, source_root: Path, *, source_read: HistorySource | None = None, + ) -> HistorySource: """Prepare a source without archive exclusion, then publish it exactly once.""" source_root = source_root.resolve() if source_root == self.path.parent.resolve(): @@ -536,15 +562,14 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou snapshot = source.provenance current = replace(bounds, traversal=traversal.for_source(index == start)) request = view.page_request(view.capture(snapshot), current) - current_snapshot = presentation.registry.snapshot() - document = presentation.bus.reads.read() - opened = Bus(Bus.path_in(Path(source.root))) - raw = request.read_opened(opened) - projected = replace(raw, messages=tuple( - HistoricalMessage.project(message, source, index, snapshot) - for message in raw.messages)) - page = replace(projected, selection=view.viewer.historical_selection( - projected, presentation, source, opened, document, current_snapshot)) + with presentation.snapshot(view.viewer) as (basis, _): + opened = Bus(Bus.path_in(Path(source.root))) + raw = request.read_opened(opened) + projected = replace(raw, messages=tuple( + HistoricalMessage.project(message, source, index, snapshot) + for message in raw.messages)) + page = replace(projected, selection=view.viewer.historical_selection( + projected, presentation, source, opened, basis.document, basis.registry)) if page.messages: return replace( page, @@ -553,6 +578,13 @@ def page(self, view: HistoryView, presentation: BusPresentation, bounds: PageBou ) return MessagePage((), False, False, NoDisplaySelection()) + def checked_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds + ) -> MessagePage: + """One integrated page read from a catalogue checked unchanged before and after it.""" + with self.reading() as captured: + return captured.integrated_page(view, presentation, bounds) + def integrated_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds ) -> MessagePage: @@ -576,11 +608,17 @@ def integrated_page( def _source_page( self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds, live_follows: Callable[[], bool], + ) -> MessagePage: + page = self._select_source_page(view, presentation, bounds, live_follows) + return replace(page, selection=replace( + page.selection, history_revision=self.history_revision, + )) + + def _select_source_page( + self, view: HistoryView, presentation: BusPresentation, bounds: PageBounds, + live_follows: Callable[[], bool], ) -> MessagePage: historical = bounds.traversal.historical - if not self.sources() and not historical: - return view.live_page(presentation, bounds) - history_revision = FileRevision.capture(self.path) def historical_page() -> MessagePage: history = self.page( view, presentation, bounds if historical else replace(bounds, traversal=OlderTraversal()) @@ -588,27 +626,21 @@ def historical_page() -> MessagePage: if history.messages: return replace( history, - selection=replace(history.selection, history_revision=history_revision), has_newer=history.has_newer or live_follows(), ) selected = history if historical else page - result = replace(selected, selection=replace(selected.selection, history_revision=history_revision)) if historical: return bounds.traversal.after_exhausted_history( lambda traversal: view.live_page(presentation, replace(bounds, traversal=traversal)), - result, + selected, ) - return result + return selected if historical: return historical_page() page = view.live_page(presentation, bounds) return bounds.traversal.source_page( - replace( - page, - has_older=page.has_older or bool(self.sources()), - selection=replace(page.selection, history_revision=history_revision), - ), + replace(page, has_older=page.has_older or bool(self.sources())), historical_page, ) @@ -620,3 +652,17 @@ def threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: for thread in source.provenance.threads.values() if name is None or thread.incarnation.matches_recorded_name(name, source.provenance)) return tuple(result) + + +@dataclass(frozen=True, slots=True) +class CapturedHistoryArchive(HistoryArchive, declared_name=None): + """One reading's immutable catalogue; page logic remains on HistoryArchive.""" + + captured_sources: tuple[HistorySource, ...] + catalogue_revision: FileObservation + + def captured(self) -> CapturedHistoryArchive: + return self + + def sources(self) -> tuple[HistorySource, ...]: + return self.captured_sources diff --git a/src/agent_comms/history_views.py b/src/agent_comms/history_views.py index 6a8a0e0b7a..660a2e7d66 100644 --- a/src/agent_comms/history_views.py +++ b/src/agent_comms/history_views.py @@ -246,26 +246,21 @@ def attach_history(self, source_root: Path, *, source_read: HistorySource | None def historical_threads(self, name: str | None = None) -> tuple[HistoricalThread, ...]: return self.bus.history.threads(name) - def dm_display_page( - self, peer: ThreadTarget, *, worktree: Checkout, bounds: PageBounds = NEWEST_PAGE - ) -> MessagePage: + def dm_display_view( + self, peer: ThreadTarget, *, worktree: Checkout, + ) -> DMDisplayHistory: viewer = self.messaging.user_identity(worktree) - return DMDisplayHistory(viewer.name, peer, worktree).page(self.presentation, bounds) + return DMDisplayHistory(viewer.name, peer, worktree) + + def channel_display_view( + self, target: str, *, viewer: DisplayViewer = AnonymousViewer(), + ) -> ChannelDisplayHistory: + return ChannelDisplayHistory(self.bus.require_channel(target), viewer) def display_viewer(self, worktree: Checkout) -> NamedViewer: """Select the human reader; page capture resolves it in its own snapshot.""" return NamedViewer(self.messaging.user_identity(worktree).name) - def channel_display_page( - self, - target: str, - *, - viewer: DisplayViewer = AnonymousViewer(), - bounds: PageBounds = NEWEST_PAGE, - ) -> MessagePage: - channel = self.bus.require_channel(target) - return ChannelDisplayHistory(channel, viewer).page(self.presentation, bounds) - def mark_page_read(self, page: MessagePage, *, worktree: Checkout) -> None: """Acknowledge the original read evidence of exactly the painted page.""" page.selection.acknowledge(self, page, worktree=worktree) diff --git a/src/agent_comms/locked_store.py b/src/agent_comms/locked_store.py index 2b57fcbddd..4be34d0c88 100644 --- a/src/agent_comms/locked_store.py +++ b/src/agent_comms/locked_store.py @@ -870,10 +870,12 @@ def _acquire_snapshot(cls, db: sqlite3.Connection) -> sqlite3.Connection: @classmethod def read_snapshot( - cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION + cls, path: Path, contention: StoreContention = DEFAULT_STORE_CONTENTION, + *, check_same_thread: bool = True, ) -> sqlite3.Connection: """Open and acquire one consistent read-only snapshot with SQLite's busy handler.""" - return cls._acquire_snapshot(cls._open_reader(path, contention)) + return cls._acquire_snapshot(cls._open_reader( + path, contention, check_same_thread=check_same_thread)) @classmethod def recovering_snapshot(cls, path: Path) -> sqlite3.Connection: diff --git a/src/agent_comms/message_bus.py b/src/agent_comms/message_bus.py index 32c5b1485a..245047dfcc 100644 --- a/src/agent_comms/message_bus.py +++ b/src/agent_comms/message_bus.py @@ -144,13 +144,17 @@ def channel_declarations(self): with self.opened_activity() as activity: return self._channels.read().views(activity) + def capture_selection(self, viewer: DisplayViewer, source: BusSnapshot) -> CapturedSelection: + return CapturedSelection.load( + self._registry, self._channels, self.reads, viewer, source.activity_clocks(), + ) + @contextmanager - def snapshot(self, viewer: DisplayViewer = AnonymousViewer()) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer = AnonymousViewer(), + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: with self.bus.reading() as source: - basis = CapturedSelection.load( - self._registry, self._channels, self.reads, viewer, source.activity_clocks(), - ) - yield basis, source + yield self.capture_selection(viewer, source), source def inbox( self, name: str, target: NamedTarget = AnyChannel.target(), *, diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index f91b7540c6..2c8bc97227 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -14,7 +14,7 @@ import sqlite3 import time from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator, Mapping, Sequence +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence from contextlib import closing from dataclasses import Field, dataclass, field, fields, make_dataclass, replace from functools import cache @@ -47,6 +47,7 @@ FieldCodec, InlineRecord, MessageId, + MalformedRecord, NullMember, PublicationExclude, ThreadLookup, @@ -429,14 +430,65 @@ def rows(self) -> tuple[CapabilityRow, ...]: @classmethod def read_from(cls, db: sqlite3.Connection, seq: int) -> Self: - """Reconstruct this declared value from its own capability tables.""" - rows = [] - for table in cls.tables(): - stored = table.select(db, seq=seq) - if not stored: - raise RelationViolationError(f"Bus row {seq} lacks {table.declared_name}") - rows.append(stored[0]) - return cls.assembled(rows) + """Reconstruct one declared value through the shared joined acquisition.""" + with closing(cls.selected( + db, f'SELECT seq FROM "{BusRow.declared_name}" WHERE seq=?', (seq,), + )) as values: + for value in values: + return value + raise RelationViolationError(f"Bus row {seq} lacks {cls.__name__}") + + @classmethod + def selected( + cls, db: sqlite3.Connection, positions: str, parameters: tuple[object, ...], + *, descending: bool = False, + ) -> Generator[Self]: + """One cursor reads only the capability tables this value inherits.""" + order = "DESC" if descending else "ASC" + with closing(cls._joined( + db, cls.tables(), f'ORDER BY {BusRow.qualified("seq")} {order}', + parameters, lambda header: cls, + prefix=f"WITH selected AS ({positions}) ", + position_join="JOIN selected USING(seq) ", + )) as rows: + for _, stored in rows: + yield stored + + @classmethod + def _joined[Stored: BusCapability]( + cls, db: sqlite3.Connection, tables: tuple[type[CapabilityRow], ...], + position_clause: str, parameters: tuple[object, ...], + record_type: Callable[[BusRow], type[Stored]], *, + prefix: str = "", position_join: str = "", + ) -> Generator[tuple[BusRow, Stored]]: + """One cursor owns declaration-order joins, decoding and required-row refusal.""" + joined: tuple[type[BusTables], ...] = (BusRow, *tables) + offsets = dict(zip( + tables, + accumulate((len(BusRow.columns()), *(len(table.columns()) for table in tables))), + strict=False, # The final accumulated value is the row end, not a table start. + )) + header = BusRow.qualified("seq") + with closing(db.execute( + f"{prefix}SELECT {', '.join(row.qualified(*row.column_projection()) for row in joined)} " + f'FROM "{BusRow.declared_name}" {position_join}' + + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' + for table in tables) + + f" {position_clause}", parameters, + )) as cursor: + for stored in cursor: + original = BusRow.decode_at(stored, 0) + kind = record_type(original) + try: + value = kind.assembled( + table.decode_at(stored, offsets[table]) for table in kind.tables() + ) + except MalformedRecord as error: + raise RelationViolationError( + f"Bus row {original.seq} has malformed required " + f"{kind.__name__} capability: {error}" + ) from error + yield original, value @classmethod def assembled(cls, rows: Iterable[CapabilityRow]) -> Self: @@ -474,24 +526,12 @@ def at_sequences(db: sqlite3.Connection, sequences: tuple[int, ...]) -> tuple[Bu @staticmethod def stored_records( db: sqlite3.Connection, where: str, parameters: tuple[object, ...], - ) -> Iterator[tuple[BusRow, BusRecord]]: - """Each selected header with the record its capability rows assemble, decoded once.""" - tables = TypedTable.members_with(CapabilityRow) - offsets = dict(zip(tables, accumulate( - (len(BusRow.columns()), *(len(table.columns()) for table in tables))))) - header = BusRow.qualified("seq") - with closing(db.execute( - f"SELECT {', '.join(row.qualified(*row.column_projection()) for row in (BusRow, *tables))} " - f'FROM "{BusRow.declared_name}" ' - + " ".join(f'LEFT JOIN "{table.declared_name}" ON {table.qualified("seq")} = {header}' - for table in tables) - + f" WHERE {where}", - parameters, - )) as cursor: - for stored in cursor: - row = BusRow.decode_at(stored, 0) - yield row, row.kind.assembled( - table.decode_at(stored, offsets[table]) for table in row.kind.tables()) + ) -> Generator[tuple[BusRow, BusRecord]]: + """Each selected header with its complete original record, decoded once.""" + return BusCapability._joined( + db, TypedTable.members_with(CapabilityRow), f"WHERE {where}", parameters, + lambda header: header.kind, + ) def projections(self) -> Iterator[TypedTable]: return iter(()) diff --git a/src/agent_comms/native_claim_epoch.py b/src/agent_comms/native_claim_epoch.py index 2bcf1d1177..1b82f64e9d 100644 --- a/src/agent_comms/native_claim_epoch.py +++ b/src/agent_comms/native_claim_epoch.py @@ -105,7 +105,7 @@ def reservation_violation(self) -> bool: def require_release(self, acknowledgement, snapshot, current) -> None: sent = ClaimedIdentity(current.incarnation, self.value) - fence = ClaimedIdentity(acknowledgement.incarnation(), acknowledgement.before) + fence = ClaimedIdentity(acknowledgement.incarnation, acknowledgement.before) if not fence.includes(sent): raise RelationViolationError("Native release predates the sending claim_scope") if snapshot.claim_generation(current.name) == acknowledgement.after: diff --git a/src/agent_comms/owner_lifecycle.py b/src/agent_comms/owner_lifecycle.py index b4b8f6983d..5f5b979826 100644 --- a/src/agent_comms/owner_lifecycle.py +++ b/src/agent_comms/owner_lifecycle.py @@ -206,7 +206,7 @@ class ReleasedOwner(ThreadIncarnationRecord): process_identity: Annotated[ProcessState, Column(storage=JsonStorage, nullable=True)] def matches(self, expected: Thread) -> bool: - return self.incarnation() == expected.incarnation and self.process_identity == expected.process_identity + return self.incarnation == expected.incarnation and self.process_identity == expected.process_identity class OwnerReleaseStoreRows(StoreRows): @@ -226,7 +226,7 @@ def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvide """The row still binds the released process or, once a dead owner's stop unregistered it, none; a different process is a successor.""" current = snapshot.require(release.name) - return (release.incarnation() == current.incarnation + return (release.incarnation == current.incarnation and not current.process_identity.conflicts_with(release.process_identity)) @@ -266,14 +266,14 @@ def require_retired(self, snapshot: RegistrySnapshot, thread: Thread, raise RelationViolationError("Retired session acquired another process") def require_loss(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> None: - if self.owner.incarnation != release.incarnation(): + if self.owner.incarnation != release.incarnation: raise RelationViolationError("Retired session belongs to another incarnation") if snapshot.owner_identity(release.name).generation <= self.owner.generation: raise RelationViolationError("Retired session still holds its original owner lease") def matches_current(self, snapshot: RegistrySnapshot, release: OwnerReleaseEvidence) -> bool: self.require_loss(snapshot, release) - return release.incarnation().current(snapshot) + return release.incarnation.current(snapshot) @dataclass(frozen=True) @@ -284,13 +284,14 @@ class OwnerReleaseEvidence(OwnerReleaseStoreRows, ReleasedOwner, OwnerRelease, T completion: Annotated[OwnerCompletion, Column()] = ProcessExitedOwnerCompletion() def __post_init__(self) -> None: + super().__post_init__() if self.before <= 0 or self.after <= self.before: raise ValueError("Owner release requires an advanced claim_scope and a closed turn") def current(self, snapshot: RegistrySnapshot, expected: Thread, claim_scope: int) -> bool: if self.before != claim_scope or not self.matches(expected): return False - if not self.incarnation().current(snapshot) or not self.completion.matches_current(snapshot, self): + if not self.incarnation.current(snapshot) or not self.completion.matches_current(snapshot, self): return False return self.after == snapshot.claim_generation(self.name) and snapshot.status(self.name).stopped @@ -311,13 +312,13 @@ def require_owner_loss(self, snapshot, owner_identity): Input generation belongs to the recorded input, not to owner-loss proof. Neither this release nor missing input proves bytes were unwritten. """ - if not self.incarnation().current(snapshot): + if not self.incarnation.current(snapshot): raise RelationViolationError("Released native owner incarnation changed") current = snapshot.require(self.name) owner_identity.require_recorded_owner(current) self.completion.require_loss(snapshot, self) actual = snapshot.claimed_identity(current.name) - released = ClaimedIdentity(self.incarnation(), self.after) + released = ClaimedIdentity(self.incarnation, self.after) if not actual.includes(released): raise RelationViolationError("Native release claim_scope regressed") return self diff --git a/src/agent_comms/presentation.py b/src/agent_comms/presentation.py index 13724e8e86..b6c0b02d06 100644 --- a/src/agent_comms/presentation.py +++ b/src/agent_comms/presentation.py @@ -5,11 +5,12 @@ from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field, replace from datetime import datetime from functools import partial from pathlib import Path +from threading import RLock from typing import TYPE_CHECKING, Annotated, ClassVar from . import relationships @@ -28,7 +29,7 @@ from .coordination_database import CoordinationStore from .coordination_errors import DMPaintedBoundChanged, StaleRevision from .declared_family import DeclaredFamily -from .display_order import ChannelSort, NameChannelSort +from .display_order import ChannelSort, NameChannelSort, ThreadSort from .display_references import CurrentDisplayReferences from .external_transcript import ExternalTranscript from .field_codec import ( @@ -37,6 +38,7 @@ JsonShapeFamily, JsonShapeMember, NullMember, + PresentMember, PresenceExclude, RecordMember, SubclassUnion, @@ -529,11 +531,30 @@ def __init__( self._display_unread = DisplayUnread() @contextmanager - def snapshot(self, viewer: NamedViewer) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + def snapshot( + self, viewer: DisplayViewer, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: """Borrow the bus-owned source cut without preparing a second store.""" with self.bus.snapshot(viewer) as selected: yield selected + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + """One page-source lifetime owns the SQLite cut and its selection/read document. + + The preparation owner may move a read between workers. A captured presentation + serializes page use and retirement, without keeping any store exclusion held. + """ + with ExitStack() as lifetime: + source = lifetime.enter_context(self.bus.bus.captured_reading()) + basis = self.bus.capture_selection(viewer, source) + captured = CapturedBusPresentation(self, basis, source) + try: + yield captured + finally: + with captured.reading(viewer): + lifetime.close() + def roster( self, viewer: str, visibility: RosterVisibility = DEFAULT_ROSTER, ) -> CoordinationSnapshot: @@ -562,7 +583,7 @@ def channel_page( ) -> MessagePage: """Local display projection; underlying channel history remains target-owned.""" channel = CommandTarget.named(target).require_channel() - with self.bus.snapshot(viewer) as (basis, source): + with self.snapshot(viewer) as (basis, source): return basis.viewer.read_page(basis.scope(channel.name), bounds, basis, source) def mark_channel_read( @@ -592,6 +613,34 @@ def display_view_metrics( return activity, counted.counts +class CapturedBusPresentation(BusPresentation): + """The existing display API borrowing one original cut, never a second store. + + Its caller closes the reading when the conversation source is replaced or retired. + Read acknowledgements still go through the live ledger and identity validation. + """ + + def __init__(self, original: BusPresentation, basis: CapturedSelection, source: BusSnapshot): + super().__init__(original.bus, original.registry, original.catalog, + original.agents, original.goals) + self.basis, self.source = basis, source + self._use = RLock() + + @contextmanager + def snapshot( + self, viewer: DisplayViewer, + ) -> Iterator[tuple[CapturedSelection, BusSnapshot]]: + with self._use: + if viewer.resolve(self.basis.registry) != self.basis.viewer: + raise ValueError("A captured display cut belongs to its original viewer") + yield self.basis, self.source + + @contextmanager + def reading(self, viewer: DisplayViewer) -> Iterator[CapturedBusPresentation]: + with self.snapshot(viewer): + yield self + + @dataclass(frozen=True, slots=True) class ChannelView(ToolResult): """Wire form: the channel's own fields beside its roster; agent counts stay in process.""" @@ -720,8 +769,30 @@ def activity_detail(self) -> str: return " · ".join(part for part in (self.activity.detail, *shown.status.details) if part) +class ThreadPresence(ABC): + """A person-row consumer visits only complete captured thread views.""" + + def views(self) -> tuple[ThreadView, ...]: + return () + + @abstractmethod + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: ... + + @dataclass(frozen=True, slots=True) -class ThreadView(ThreadRecord): +class NoThreadView(NullMember, ThreadPresence): + """A named navigation target has no captured thread presentation.""" + + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: + return order.key(target, 0, 0, 0) + + +@dataclass(frozen=True, slots=True) +class ThreadView(ThreadRecord, PresentMember, ThreadPresence): activity: Annotated[ObservedActivity, PresenceExclude()] runtime: Annotated[RuntimeInfo, PresenceExclude()] last_seen: float @@ -745,6 +816,14 @@ def roster( snapshot.seen_at(thread.name), snapshot.owner_binding(thread.name), ) for thread in threads) + def views(self) -> tuple[ThreadView, ...]: + return (self,) + + def sort_key( + self, order: type[ThreadSort], target: str, sent: float, + ) -> tuple[float, float, str]: + return order.key(target, self.thread.birth, self.activity.timestamp, sent) + @property def presentation(self) -> ThreadPresentation: """One declaration-owned interpretation for every thread view.""" diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index 3658398384..53caf0ab68 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -6,10 +6,11 @@ """ from __future__ import annotations + from agent_comms.repo import Checkout from .bus_tables import AwaitingAnswerSource -from .messages import MessageInput +from .messages import BusRecord, MessageInput from .class_fact import ClassFact import asyncio @@ -28,13 +29,14 @@ from .coordination_errors import DMPaintedBoundChanged, DMScopeChanged, HistoricalSourceChanged from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError -from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion +from .field_codec import FieldCodec, NullMember, PathText, SingleFieldValue, SubclassUnion, WireTransient from .mentions import ThreadMention from .message_reference import MessageReference from .private_path import FileObservation, MissingFile from .projection_source import ProducedRow, ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation +from .thread_membership import ThreadMembership class DisplayViewer(SubclassUnion, affix="Viewer"): @@ -728,16 +730,28 @@ def fetch_page( Fetching is not paint proof. A UI may use the basis only after proving that the corresponding inbound tail was contiguous and visibly painted. """ - snapshot = registry.snapshot() - viewer_name = snapshot.canonical_name(viewer.name) + with bus.snapshot(NamedViewer(viewer.name)) as (selection, source): + return cls.fetch_captured( + bus, registry, root, peer, selection, source, + worktree=worktree, bounds=bounds, + ) + + @classmethod + def fetch_captured( + cls, bus: MessageBus, registry: Registration, root: Path, + peer: ThreadTarget, selection: CapturedSelection, source: BusSnapshot, + *, worktree: Checkout, bounds: PageBounds, + ) -> MessagePage: + """Build each DM page from the page source's original identity/read cut.""" + snapshot = selection.registry + viewer_name = selection.require_viewer() peer_name = peer.selected_name(snapshot) scope = DMDisplayScope.capture_human(viewer_name, peer_name, snapshot) viewer_thread = snapshot.threads[viewer_name] peer_thread = snapshot.participant(peer_name) viewer_names, peer_names = scope.first_names, scope.second_names - document = bus.reads.read_conversation(viewer_name, scope.read_key(viewer_name, peer_name, snapshot), snapshot) result = bounds.conversation(scope).displayed( - bus.reads, document, viewer_name, snapshot, snapshot, bus.bus, + selection.reads, selection.document, viewer_name, snapshot, snapshot, source, ) page, displayed = result.page, result.displayed root_info = root.stat() @@ -875,20 +889,23 @@ def records( return source.scoped_page(predicate, parameters, lower=lower, upper=upper, descending=descending, limit=limit, kinds=kinds) - def history(self, source: BusReader, *, limit: int) -> tuple[Message, ...]: - """A bounded recent selection closed over its answers, without crossing this scope. + def history(self, source: BusReader, *, limit: int) -> tuple[BusRecord, ...]: + """Full copy records selected by this scope, closed over their disclosed answers. - A copied message keeps only the answers this scope discloses: a reference to a message - outside it would name that message, and its original stays on the source bus.""" - selected: dict[int, Message] = {} + Display pages stay lightweight. A copy acquires each original record on the held + reader so its declared history_record retains deliveries and removes claims. + References outside this scope stay on the source bus. + """ + selected: dict[int, BusRecord] = {} with source.reading() as reader: for message in self.records(reader, descending=True, limit=limit): - added: dict[int, Message] = {} + added: dict[int, BusRecord] = {} pending = [message] while pending and len(selected) + len(added) <= limit: - answer = pending.pop() - if answer.seq in selected or answer.seq in added: + candidate = pending.pop() + if candidate.seq in selected or candidate.seq in added: continue + answer = reader.lookup(candidate.seq) disclosed = tuple(original for original in reader.messages_for_references(answer.answers) if self.includes(original)) @@ -951,6 +968,13 @@ def counted(self, cursor: ReadCursor, snapshot: RegistryProvenance return ((key, value) for key, value in cursor.conversations.items() if key.in_targets(targets, snapshot)) + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return {} + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return True + @abstractmethod def includes(self, message: Message) -> bool: """Apply the captured predicate to a validated authoritative message.""" @@ -1122,10 +1146,15 @@ def capture_all(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot @dataclass(frozen=True, slots=True) -class MemberActivityDisplayScope(AllTargetsDisplayScope): - """Member activity may match a sender, DM peer or mention on any target.""" +class ParticipantActivityDisplayScope(AllTargetsDisplayScope, declared_name=None): + """Sender, DM peer and mention activity share one relational predicate.""" + + @property + @abstractmethod + def participant_query(self) -> tuple[str, tuple[object, ...]]: ... - participant_names: frozenset[str] = frozenset() + @abstractmethod + def includes_participant(self, name: str) -> bool: ... def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistrySnapshot, source: BusSnapshot, *, document: ReadDocument, @@ -1134,40 +1163,76 @@ def capture_deliveries(self, reads: ReadLedger, viewer: str, snapshot: RegistryS rows = source.selected_sequences(*self.intersection(audience), through) return reads.capture(viewer, rows, snapshot, source, document=document) - @classmethod - def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: - members = frozenset(name for name, thread in snapshot.threads.items() - if channel.tags <= thread.tags) - names = members | frozenset(alias for alias, owner in snapshot.aliases.items() - if owner in members) - return cls(channel.name, channel.history_targets, participant_names=names) - @property def condition(self) -> tuple[str, tuple]: - targets, names = tuple(self.targets), tuple(self.participant_names) - target_marks, name_marks = ",".join("?" for _ in targets), ",".join("?" for _ in names) + targets = tuple(self.targets) + members, parameters = self.participant_query + target_marks = ",".join("?" for _ in targets) mention_key = FieldCodec.field_plan(ThreadMention, "thread").key - return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({name_marks}) OR " - f"({{target}} NOT LIKE '#%' AND {{target}} IN ({name_marks})) OR " + return (f"{{target}} IN ({target_marks}) OR {{sender}} IN ({members}) OR " + f"({{target}} NOT LIKE '#%' AND {{target}} IN ({members})) OR " f"EXISTS (SELECT 1 FROM json_each({{mentions}}) " - f"WHERE json_extract(value,?) IN ({name_marks}))", - (*targets, *names, *names, f"$.{mention_key}", *names)) + f"WHERE json_extract(value,?) IN ({members}))", + (*targets, *parameters, *parameters, f"$.{mention_key}", *parameters)) def includes(self, message: Message) -> bool: return self._includes(message.sender, message.target, (mention.thread for mention in message.mentions)) def activity(self, source: BusReader): - + members, parameters = self.participant_query return ChannelActivity.combine(( ChannelDisplayScope(self.channel, self.targets).activity(source), - source.participant_activity(self.participant_names), + source.participant_activity(f"participant IN ({members})", parameters), )) def _includes(self, sender: str, target: str, mentions: Iterable[str]) -> bool: - return (target in self.targets or sender in self.participant_names - or not self.participant_names.isdisjoint(CommandTarget.named(target).direct_names) - or any(name in self.participant_names for name in mentions)) + return (target in self.targets or self.includes_participant(sender) + or any(self.includes_participant(name) + for name in CommandTarget.named(target).direct_names) + or any(self.includes_participant(name) for name in mentions)) + + +@dataclass(frozen=True, slots=True) +class NamedActivityDisplayScope(ParticipantActivityDisplayScope): + """An explicitly selected set, used by root checks rather than channel tags.""" + + participants: frozenset[str] = frozenset() + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return "SELECT value FROM json_each(?)", (json.dumps(sorted(self.participants)),) + + def includes_participant(self, name: str) -> bool: + return name in self.participants + + +@dataclass(frozen=True, slots=True) +class MemberActivityDisplayScope(ParticipantActivityDisplayScope): + """A channel declaration joined to the original immutable namespace's tags.""" + + tags: frozenset[str] = frozenset() + registry: Annotated[RegistryProvenance, WireTransient()] = field( + kw_only=True, compare=False, repr=False, + ) + + @classmethod + def capture(cls, channel: TagChannel, snapshot: RegistryProvenance) -> MemberActivityDisplayScope: + return cls(channel.name, channel.history_targets, tags=channel.tags, registry=snapshot) + + @property + def memberships(self) -> Mapping[str, ThreadMembership]: + return self.registry.memberships + + def same_membership(self, earlier: MessageDisplayScope) -> bool: + return self.registry.same_tag_membership(earlier.memberships, self.tags) + + @property + def participant_query(self) -> tuple[str, tuple[object, ...]]: + return self.registry.tagged_names(self.tags) + + def includes_participant(self, name: str) -> bool: + return name in self.memberships and self.memberships[name].carries(self.tags) @dataclass(frozen=True, slots=True) @@ -1178,18 +1243,36 @@ class UnreadQuery: moved for some conversations differs from its earlier count only in those conversations' rows above the lower of their two high waters.""" - channel: str + scope: ChannelDisplayScope cursors: tuple[CursorRow, ...] lower_bound: int - predicate: str - parameters: tuple viewer_names: str + target_groups: tuple[tuple[str, ...], ...] = () + + @property + def channel(self) -> str: + return self.scope.channel + + @property + def predicate(self) -> str: + predicate, _ = self.scope.index_predicate + for targets in self.target_groups: + predicate = f"({predicate}) AND target IN ({', '.join('?' * len(targets))})" + return predicate + + @property + def parameters(self) -> tuple: + _, parameters = self.scope.index_predicate + return (*parameters, *(target for targets in self.target_groups for target in targets)) + + def same_membership(self, earlier: UnreadQuery) -> bool: + return self.scope.same_membership(earlier.scope) @property def counting(self) -> UnreadQuery: """The same rows and rule with no read cursor: what stays fixed while reading. The lower bound is the cursor's own floor over the scope's targets, so it moves with it.""" - return replace(self, cursors=(), lower_bound=0) + return replace(self, cursors=(), lower_bound=0, target_groups=()) def moved(self, earlier: UnreadQuery) -> tuple[UnreadQuery, ...]: """The earlier and current counts over only the conversations whose cursor moved; @@ -1209,8 +1292,7 @@ def within(self, targets: tuple[str, ...], floor: int) -> UnreadQuery: """This query over only these targets' rows above floor; every row at or below a moved conversation's lower high water is read under both cursors.""" return replace(self, lower_bound=max(self.lower_bound, floor), - predicate=f"({self.predicate}) AND target IN ({', '.join('?' * len(targets))})", - parameters=(*self.parameters, *targets)) + target_groups=(*self.target_groups, targets)) @dataclass(frozen=True, slots=True) @@ -1227,10 +1309,9 @@ def queries(self) -> tuple[UnreadQuery, ...]: names = json.dumps(self.viewer_names) return tuple( UnreadQuery( - scope.channel, + scope, ReadCursor.rows(self.registry, {"viewer": self.seen.counted(scope, self.registry)}), self.seen.lower_bound(scope, self.registry), - *scope.index_predicate, names, ) for scope in self.scopes diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 328bb2fb17..e93c38e6e8 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -21,6 +21,7 @@ from .declared_family import DeclaredFamily from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( + DeclaredWireType, ExternalJsonShape, FieldCodec, MalformedRecord, @@ -29,6 +30,7 @@ ThreadLookup, WireOmitDefault, WireRequired, + WireTransient, WireValue, ) from .registration_change import ( @@ -57,6 +59,7 @@ OfferedTools, OwnerIdentity, RecordedThreadIncarnation, + ThreadBirthRecord, ThreadIncarnation, ThreadIncarnationRecord, ThreadAuthority, @@ -71,7 +74,8 @@ ThreadStatus, ) from .thread_text import ForkedParent, NoParent -from .threads import Thread, ThreadWorkState +from .thread_membership import ThreadMembership +from .threads import GoalOwner, Thread, ThreadWorkState from .turn_lease import ( FinishedTurn, FinishedTurnFence, NoFinishedTurn, StoredTurnRecord, TurnCompletion, TurnLeaseFence, TurnRecord, @@ -108,6 +112,13 @@ class RegistryPresence[StatusMap: Mapping[str, ThreadStatus]](RegistryNames[Thre def children(self, name: str) -> tuple[Thread, ...]: return self.threads.children(self.canonical_name(name)) + def incarnation(self, name: str) -> ThreadIncarnation: + """This captured row owns identity without acquiring its full declaration.""" + return self.threads.incarnation(self.require_canonical(name)) + + def membership(self, name: str) -> ThreadMembership: + return self.threads.membership(self.require_canonical(name)) + def active_candidates(self) -> Iterator[Thread]: return (self.threads[name] for name, status in self.statuses.items() if status.may_be_active) @@ -312,13 +323,85 @@ def __init__(self, error: Exception) -> None: super().__init__(f"Invalid registry document: {error}") +@dataclass(frozen=True) +class ThreadRow(DeclaredWireType): + """One retained row owns its lazy birth, narrow projections and bound Thread.""" + + wire_type = Thread + raw: Annotated[Mapping[str, JsonValue], WireTransient()] + binding: Annotated[Callable[[Thread], Thread], WireTransient()] = field(compare=False) + + @classmethod + def from_wire(cls, raw: object) -> Self: + if not isinstance(raw, dict): + raise MalformedRecord("thread row must be an object") + return cls(MappingProxyType(raw), ThreadRows.unbound) + + @staticmethod + def from_thread( + thread: Thread, binding: Callable[[Thread], Thread], + ) -> WrittenThreadRow: + return WrittenThreadRow(MappingProxyType(thread.to_wire()), binding, thread) + + def bound(self, binding: Callable[[Thread], Thread]) -> Self: + return replace(self, binding=binding) + + def to_wire(self) -> dict[str, JsonValue]: + return dict(self.raw) + + def __reduce__(self) -> tuple[ + Callable[[object], ThreadRow], tuple[dict[str, JsonValue]], + dict[str, Callable[[Thread], Thread]], + ]: + # Pickle restores a lazy wire row, retaining this cut's physical binding. + return ThreadRow.from_wire, (self.to_wire(),), {"binding": self.binding} + + @cached_property + def birth_record(self) -> ThreadBirthRecord: + return FieldCodec.project_record(ThreadBirthRecord, dict(self.raw), source=Thread) + + @cached_property + def membership(self) -> ThreadMembership: + return FieldCodec.project_record( + ThreadMembership, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), + ) + + @cached_property + def goal_owner(self) -> GoalOwner: + return FieldCodec.project_record( + GoalOwner, dict(self.raw), source=Thread, + decoded_fields=FieldCodec.recorded_fields(self.birth_record), + ) + + @cached_property + def decoded(self) -> Thread: + try: + thread = FieldCodec.decode_record( + Thread, dict(self.raw), + decoded_fields=FieldCodec.recorded_fields(self.goal_owner), + ) + except ValueError as error: + raise InvalidRegistryDocument(error) from error + return self.binding(thread) + + +@dataclass(frozen=True) +class WrittenThreadRow(ThreadRow): + """An assigned row already holds its full Thread; no wire decode supplies it.""" + + thread: Annotated[Thread, WireTransient()] + + @cached_property + def decoded(self) -> Thread: + return self.binding(self.thread) + + class ThreadRows(ExternalJsonShape, WireValue, MutableMapping[str, Thread]): """A revision's thread rows, decoded and bound only when read.""" def __init__(self) -> None: - # checked_rows proved each stored row an object, so the type keeps that proof. - self._raw: dict[str, dict[str, JsonValue]] = {} - self._decoded: dict[str, Thread] = {} + self._rows: dict[str, ThreadRow] = {} self._binding: Callable[[Thread], Thread] = self.unbound @staticmethod @@ -326,18 +409,18 @@ def unbound(thread: Thread) -> Thread: return thread def bind(self, binding: Callable[[Thread], Thread]) -> None: + if self._binding == binding: + return self._binding = binding - self._decoded = {name: binding(thread) for name, thread in self._decoded.items()} + self._rows = {name: row.bound(binding) for name, row in self._rows.items()} def __eq__(self, other: object) -> bool: - """Rows still stored on both sides compare as their JSON; only rows that differ there, - or that either side wrote, are decoded, so a one-row edit never decodes the registry. + """Equal retained wire needs no full decoding; differing rows compare their Threads. Rows equal only rows: a plain mapping is a different value.""" if not isinstance(other, ThreadRows): return NotImplemented return self.keys() == other.keys() and all( - (name in self._raw and name in other._raw and self._raw[name] == other._raw[name]) - or self[name] == other[name] + self._rows[name].raw == other._rows[name].raw or self[name] == other[name] for name in self) def children(self, parent: str) -> tuple[Thread, ...]: @@ -346,46 +429,49 @@ def children(self, parent: str) -> tuple[Thread, ...]: expected = FieldCodec.encode(ForkedParent(parent)) return tuple( self[name] for name in self - if (self._decoded[name].parent.named(parent) if name in self._decoded - else self._raw[name].get(key) == expected) + if self._rows[name].raw.get(key) == expected ) - def __getitem__(self, name: str) -> Thread: - if name not in self._decoded: - self._decoded[name] = self.decode_row(self._raw[name]) - return self._decoded[name] + def determining(self, name: str) -> GoalOwner: + """Only a goal/role question acquires the declared goal-owner projection.""" + return self._rows[name].goal_owner - def decode_row(self, row: object) -> Thread: - try: - thread = FieldCodec.decode(Thread, row) - except ValueError as error: - raise InvalidRegistryDocument(error) from error - return self._binding(thread) + def membership(self, name: str) -> ThreadMembership: + return self._rows[name].membership + + def birth_record(self, name: str) -> ThreadBirthRecord: + return self._rows[name].birth_record + + def incarnation(self, name: str) -> ThreadIncarnation: + return self.birth_record(name).incarnation + + def active_goal_owners(self) -> Iterator[Thread]: + """Select through the shared goal/role declarations before full thread acquisition.""" + return (self[name] for name in self for _ in self.determining(name).active_goals()) + + def __getitem__(self, name: str) -> Thread: + return self._rows[name].decoded def __iter__(self) -> Iterator[str]: - return iter(dict.fromkeys((*self._raw, *self._decoded))) + return iter(self._rows) def __len__(self) -> int: - return len(self._raw.keys() | self._decoded.keys()) + return len(self._rows) def __contains__(self, name: object) -> bool: - return name in self._raw or name in self._decoded + return name in self._rows def __setitem__(self, name: str, thread: Thread) -> None: if thread.name != name: raise MalformedRecord("thread name differs from its declaration") - self._decoded[name] = self._binding(thread) - self._raw.pop(name, None) + self._rows[name] = ThreadRow.from_thread(thread, self._binding) def __delitem__(self, name: str) -> None: - if name not in self: - raise KeyError(name) - self._raw.pop(name, None) - self._decoded.pop(name, None) + del self._rows[name] def to_wire(self) -> dict[str, JsonValue]: - return {**self._raw, **{name: FieldCodec.encode(thread) - for name, thread in self._decoded.items()}} + # Full acquisition never replaces the retained wire; assignment builds a new row. + return {name: row.to_wire() for name, row in self._rows.items()} @classmethod def schema(cls) -> dict[str, JsonValue]: @@ -396,31 +482,28 @@ def schema(cls) -> dict[str, JsonValue]: def checked_rows(cls, raw: object) -> dict[str, dict[str, JsonValue]]: if not isinstance(raw, dict): raise MalformedRecord("registry threads must be an object") + name_key = FieldCodec.field_plan(Thread, "name").key for name, row in raw.items(): - if not isinstance(name, str) or not isinstance(row, dict) or row.get("name") != name: + if not isinstance(name, str) or not isinstance(row, dict) or row.get(name_key) != name: raise MalformedRecord("thread name differs from its declaration") return dict(raw) def birth(self, name: str) -> CreationTime: """One row's creation time, read alone: a stored row is never decoded for it.""" - if name in self._decoded: - return self._decoded[name].created_at try: - return CreationTime.from_wire(self._raw[name].get("created_at")) + return self._rows[name].birth_record.created_at except ValueError as error: raise InvalidRegistryDocument(error) from error def births(self) -> set[float]: - return {self.birth(name) for name in self} + return {self.birth(name).to_wire() for name in self} - def lookup_names(self) -> dict[ThreadLookup, str]: - """Each row's creation lookup, read from its birth alone: resolving one owner never - decodes the other threads.""" - return { - ThreadIncarnationRecord(name, self.birth(name).to_wire()).incarnation() - .require_recorded().lookup: name - for name in self - } + def lookup_names(self) -> Mapping[ThreadLookup, str]: + """Derive this cut's lookup names from the declared birth owner, never full threads.""" + return MappingProxyType({ + ThreadIncarnationRecord(name, self.birth(name).to_wire()).incarnation + .require_recorded().lookup: name for name in self + }) def require_distinct_births(self) -> None: """Rows decode late, so a read of one thread never sees two sharing a creation lookup.""" @@ -431,7 +514,8 @@ def require_distinct_births(self) -> None: @classmethod def from_wire(cls, raw: object) -> Self: result = cls() - result._raw = cls.checked_rows(raw) + result._rows = {name: ThreadRow.from_wire(row) + for name, row in cls.checked_rows(raw).items()} return result @classmethod @@ -440,24 +524,21 @@ def from_threads(cls, threads: Mapping[str, Thread]) -> Self: result.update(threads) return result - def copy(self) -> ThreadRows: - result = ThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding - return result - - def freeze(self) -> ThreadRows: - result = FrozenThreadRows() - result._raw = self._raw.copy() - result._decoded = self._decoded.copy() - result._binding = self._binding + @classmethod + def copied(cls, source: ThreadRows) -> Self: + result = cls() + result._rows = source._rows.copy() + result._binding = source._binding return result def reuse(self, rows: Mapping[str, object], kept: frozenset[str], earlier: ThreadRows) -> None: - self._raw = self.checked_rows(rows) - self._decoded = {name: earlier._decoded[name] for name in kept if name in earlier._decoded} + changed = self.checked_rows({name: row for name, row in rows.items() if name not in kept}) self._binding = earlier._binding + self._rows = { + name: (earlier._rows[name] if name in kept else + ThreadRow.from_wire(changed[name]).bound(self._binding)) + for name in rows + } class FrozenThreadRows(ThreadRows): @@ -471,11 +552,11 @@ def __delitem__(self, name: str) -> None: class ThreadRowsSlice(OwnRow[ThreadRows]): @classmethod def copy(cls, value: ThreadRows) -> ThreadRows: - return value.copy() + return ThreadRows.copied(value) @classmethod def freeze(cls, value: ThreadRows) -> ThreadRows: - return value.freeze() + return FrozenThreadRows.copied(value) @classmethod def reuse(cls, rows: Mapping[str, object], kept: frozenset[str], @@ -1134,7 +1215,7 @@ def __post_init__(self) -> None: @cached_property def lookup_names(self) -> Mapping[ThreadLookup, str]: - return MappingProxyType(self.threads.lookup_names()) + return self.threads.lookup_names() def born(self, lookup: ThreadLookup) -> Thread: """The one thread a creation lookup names; only its row decodes.""" @@ -1259,7 +1340,9 @@ def process_owners(self, process: ProcessIdentity) -> tuple[str, ...]: def owner_identity(self, name: str) -> OwnerIdentity: canonical = self.canonical_name(name) - return OwnerIdentity(self.threads[canonical].incarnation, self.owner_generations[canonical]) + return OwnerIdentity( + self.threads[canonical].incarnation, self.owner_generations[canonical], + ) def claimed_identity(self, name: str) -> ClaimedIdentity: canonical = self.canonical_name(name) diff --git a/src/agent_comms/registry_provenance.py b/src/agent_comms/registry_provenance.py index cfb9219477..c17742c40e 100644 --- a/src/agent_comms/registry_provenance.py +++ b/src/agent_comms/registry_provenance.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from functools import cached_property, partial from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Generic, TypeVar from agent_comms.field_codec import FieldCodec, ThreadLookup @@ -18,6 +19,7 @@ from .messages import PublicSender from .routing import DeliveryScope from .thread_identity import ThreadIncarnation +from .thread_membership import ThreadMembership from .threads import Thread, ThreadProvenance if TYPE_CHECKING: @@ -135,11 +137,22 @@ def claim_name(self, wanted: str, owner: str | None = None) -> str: name, suffix = f"{base}-{suffix}", suffix + 1 return name + def declared_names(self) -> Iterator[str]: + """The current namespace, including each declaration's retained aliases.""" + yield from self.threads + yield from self.aliases + def named_members(self) -> Iterator[tuple[str, Member]]: """Original declarations under their canonical and retained alias names.""" - yield from self.threads.items() - for alias in self.aliases: - yield alias, self.require(alias) + return ((name, self.require(name)) for name in self.declared_names()) + + def incarnation(self, name: str) -> ThreadIncarnation: + """Recorded namespaces already hold their original nominal members.""" + return self.require(name).incarnation + + def named_incarnations(self) -> Iterator[tuple[str, ThreadIncarnation]]: + """Project identity through the namespace owner, never a consumer's roster.""" + return ((name, self.incarnation(name)) for name in self.declared_names()) def names_for(self, name: str) -> frozenset[str]: """Current and retained names of one declared member.""" @@ -168,12 +181,21 @@ def participant(self, name: str) -> Member | DeletedThread: return DeletedThread(canonical) raise UnregisteredThreadError(f"Thread {canonical!r} was never registered.") - def require(self, name: str) -> Member: + def registered(self, name: str) -> bool: + """Current membership, including retained aliases, without acquiring a Thread.""" + return self.canonical_name(name) in self.threads + + def require_canonical(self, name: str) -> str: canonical = self.canonical_name(name) - try: - return self.threads[canonical] - except KeyError as error: - raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") from error + if canonical not in self.threads: + raise UnregisteredThreadError(f"Thread {canonical!r} is not registered.") + return canonical + + def require(self, name: str) -> Member: + return self.threads[self.require_canonical(name)] + + def membership(self, name: str) -> ThreadMembership: + return self.require(name) @dataclass(frozen=True, slots=True) @@ -200,6 +222,45 @@ def require_alias_names(self) -> None: if any(target not in names or alias in names for alias, target in self.aliases.items()): raise RelationViolationError("Provenance alias has no exclusive retained owner") + @cached_property + def memberships(self) -> Mapping[str, ThreadMembership]: + """One immutable tag/identity relation from this namespace, including retained aliases.""" + canonical = {name: self.membership(name) for name in self.threads} + return MappingProxyType({ + **canonical, + **{alias: canonical[owner] for alias, owner in self.aliases.items()}, + }) + + @cached_property + def membership_json(self) -> str: + return FieldCodec.compact(FieldCodec.encode( + dict(self.memberships), dict[str, ThreadMembership], + )) + + def tagged_names(self, tags: frozenset[str]) -> tuple[str, tuple[object, ...]]: + """A relational tag predicate, not an expanded channel-member name list.""" + key = FieldCodec.field_plan(ThreadMembership, "tags").key + return ( + "SELECT member.key FROM json_each(?) AS member " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(?) AS wanted " + "WHERE NOT EXISTS (SELECT 1 FROM json_each(member.value, ?) AS owned " + "WHERE owned.value = wanted.value))", + (self.membership_json, json.dumps(sorted(tags)), f"$.{key}"), + ) + + def same_tag_membership(self, earlier: Mapping[str, ThreadMembership], + tags: frozenset[str]) -> bool: + """Only a matching name or its actual birth changing invalidates that channel.""" + current = self.memberships + for name in current.keys() | earlier.keys(): + before = name in earlier and earlier[name].carries(tags) + after = name in current and current[name].carries(tags) + if before != after: + return False + if before and earlier[name].incarnation != current[name].incarnation: + return False + return True + def bound_to(self, root: Path) -> RegistryProvenance: """Recorded transcripts resolve under the root this namespace was captured from.""" threads = {name: replace(thread, session_file=thread.session_file.bound_to(root)) diff --git a/src/agent_comms/relationships.py b/src/agent_comms/relationships.py index 97dda8bae1..cfdb277dae 100644 --- a/src/agent_comms/relationships.py +++ b/src/agent_comms/relationships.py @@ -9,17 +9,18 @@ import time from abc import ABC, abstractmethod -from collections.abc import Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field, fields, replace from functools import cmp_to_key from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, ClassVar, Literal from agent_comms.field_codec import GoalId from agent_comms import field_codec from . import presentation -from .channel_targets import CommandTarget +from .channel_targets import CommandTarget, NamedTarget from .command import Command from .declared_family import DeclaredFamily from .display_order import CreatedThreadSort, LastActivityThreadSort, ThreadSort @@ -27,7 +28,7 @@ from .goals import Goal, GoalMentionBinding from .locked_store import DocumentChange from .maintenance_barrier import CarriedDocument -from .messages import Message +from .messages import MessageInput from .peer_relevance import ( ChannelmateRelevance, CollaboratorRelevance, @@ -100,10 +101,11 @@ def incident(self, thread: Thread) -> bool: def entry_goal_contacts(self) -> tuple[GoalDerivedContact, ...]: return () - def entry(self, identity: ThreadIncarnation, person: presentation.ThreadView | None, + def entry(self, identity: ThreadIncarnation, + person: presentation.ThreadView | presentation.NoThreadView, available: bool, current: RelationshipEntry | None) -> RelationshipEntry: return RelationshipEntry( - identity.name, "thread", person if available else None, + identity.name, "thread", person if available else presentation.NoThreadView(), detail=f"{current.detail}\n{self.note}" if current and current.detail else self.note, available=available, sources=(*current.sources, self.source) if current else (self.source,), @@ -111,12 +113,11 @@ def entry(self, identity: ThreadIncarnation, person: presentation.ThreadView | N if current else self.entry_goal_contacts(), ) - def counterpart(self, thread: Thread) -> ThreadIncarnation | None: + def counterparts(self, thread: Thread) -> Iterator[ThreadIncarnation]: if self.owner_incarnation == thread.incarnation: - return self.peer_incarnation - if self.peer_incarnation == thread.incarnation: - return self.owner_incarnation - return None + yield self.peer_incarnation + elif self.peer_incarnation == thread.incarnation: + yield self.owner_incarnation @dataclass(frozen=True, slots=True) @@ -242,7 +243,7 @@ class GoalMentionDiagnostic: class RelationshipEntry: target: str kind: str - person: presentation.ThreadView | None = None + person: presentation.ThreadView | presentation.NoThreadView sequence: int = 0 timestamp: float = 0 detail: str = "" @@ -258,10 +259,11 @@ def revalidates(self, earlier: RelationshipEntry) -> bool: """A fresh navigation target must retain its identity and incarnation.""" if not self.available or self.identity != earlier.identity: return False - if earlier.person is None: - return True - return (self.person is not None - and self.person.thread.incarnation == earlier.person.thread.incarnation) + return all( + any(person.thread.incarnation == previous.thread.incarnation + for person in self.person.views()) + for previous in earlier.person.views() + ) @dataclass(frozen=True, slots=True) @@ -276,11 +278,35 @@ class RelationshipGroup(DeclaredFamily, affix="RelationshipGroup"): @classmethod @abstractmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: ... + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: ... + + @classmethod + def build(cls, context: RelationshipContext, + sources: tuple[RelationshipEntrySource, ...]) -> RelationshipGroup: + entries: dict[str, RelationshipEntry] = {} + for source in sources: + entry = source.entry(context) + entries[entry.target] = entry + return cls.from_entries(entries, context) @classmethod - def build(cls, context: RelationshipContext) -> RelationshipGroup: - return cls({entry.target: entry for entry in cls.supply(context)}) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> RelationshipGroup: + return cls(entries) + + @classmethod + def capture(cls, inputs: RelationshipInputs, views: HistoryViews) -> tuple[RelationshipGroup, ...]: + """Retain original inputs once, then construct complete results on one roster cut.""" + selected = tuple((group, group.sources(inputs)) for group in cls.members_with(cls)) + names = dict.fromkeys(source.name for _, sources in selected for source in sources) + people = MappingProxyType({view.thread.name: view for view in presentation.ThreadView.roster( + inputs.registry, views.agents, views.goals, RosterVisibility(archived=True), names=names, + )}) + context = RelationshipContext( + **{item.name: getattr(inputs, item.name) for item in fields(RelationshipInputs)}, + people=people, + ) + return tuple(group.build(context, sources) for group, sources in selected) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return () @@ -288,21 +314,18 @@ def selectable(self) -> tuple[SortableRelationshipGroup, ...]: class RecentRelationshipGroup(RelationshipGroup): @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries = {} - for message in reversed(context.messages): + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[str, RecentEntrySource] = {} + for message in reversed(inputs.messages): if not message.role.counts_as_conversation(): continue - for target in cls.targets(context, message): - entries.setdefault(target, context.entry( - target, message=message, - detail=f"{message.sender} → {message.target}\n{message.body[:240]}", - )) - return tuple(entries.values()) + for target in cls.targets(inputs, message): + selected.setdefault(target.name, RecentEntrySource(target, message)) + return tuple(selected.values()) @staticmethod @abstractmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: ... + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: ... class InboundRelationshipGroup(RecentRelationshipGroup): @@ -310,11 +333,12 @@ class InboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent inbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: - sender = context.registry.canonical_name(message.sender) - if sender == context.thread.name or not context.delivery.delivers(message.sender, message.target): + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + sender = inputs.registry.canonical_name(message.sender) + if sender == inputs.thread.name or not inputs.delivery.delivers(message.sender, message.target): return () - return CommandTarget.named(message.target).inbound_relations(sender) + return tuple(RelationshipEntrySource.canonical_target(name, inputs.registry) + for name in CommandTarget.named(message.target).inbound_relations(sender)) class OutboundRelationshipGroup(RecentRelationshipGroup): @@ -322,10 +346,10 @@ class OutboundRelationshipGroup(RecentRelationshipGroup): empty_text = "No recent outbound" @staticmethod - def targets(context: RelationshipContext, message: Message) -> tuple[str, ...]: - if context.registry.canonical_name(message.sender) != context.thread.name: + def targets(inputs: RelationshipInputs, message: MessageInput) -> tuple[NamedTarget, ...]: + if inputs.registry.canonical_name(message.sender) != inputs.thread.name: return () - return (CommandTarget.named(message.target).canonical_name(context.registry.canonical_name),) + return (RelationshipEntrySource.canonical_target(message.target, inputs.registry),) class ParentRelationshipGroup(RelationshipGroup): @@ -333,8 +357,11 @@ class ParentRelationshipGroup(RelationshipGroup): empty_text = "Not forked" @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - return tuple(context.entry(name) for name in context.thread.parent.names()) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + return tuple( + NamedEntrySource(RelationshipEntrySource.canonical_target(name, inputs.registry)) + for name in inputs.thread.parent.names() + ) @dataclass(frozen=True, slots=True) @@ -345,23 +372,20 @@ class SortableRelationshipGroup(RelationshipGroup): default_order: ClassVar[type[ThreadSort]] @classmethod - def selected_order(cls, context: RelationshipContext) -> type[ThreadSort]: - return context.orders.get(cls, cls.default_order) + def selected_order( + cls, orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]], + ) -> type[ThreadSort]: + return orders.get(cls, cls.default_order) @classmethod - def build(cls, context: RelationshipContext) -> SortableRelationshipGroup: - order = cls.selected_order(context) + def from_entries(cls, entries: dict[str, RelationshipEntry], + context: RelationshipContext) -> SortableRelationshipGroup: + order = cls.selected_order(context.orders) def key(item: RelationshipEntry) -> tuple[float, float, str]: - person = item.person - return order.key( - item.target, - person.thread.created_at if person else 0, - person.activity.timestamp if person else 0, - context.sent.get(item.target, 0) if person else 0, - ) + return item.person.sort_key(order, item.target, context.sent.get(item.target, 0)) - return cls({entry.target: entry for entry in sorted(cls.supply(context), key=key)}, order) + return cls({entry.target: entry for entry in sorted(entries.values(), key=key)}, order) def selectable(self) -> tuple[SortableRelationshipGroup, ...]: return (self,) @@ -381,12 +405,10 @@ class ChildrenRelationshipGroup(SortableRelationshipGroup): default_order = CreatedThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: return tuple( - context.entry(child.name) - for child in context.registry.threads.values() - for parent in child.parent.names() - if context.registry.canonical_name(parent) == context.thread.name + NamedEntrySource(RelationshipEntrySource.canonical_target(child.name, inputs.registry)) + for child in inputs.registry.children(inputs.thread.name) ) @@ -396,40 +418,101 @@ class CollaboratingRelationshipGroup(SortableRelationshipGroup): default_order = LastActivityThreadSort @classmethod - def supply(cls, context: RelationshipContext) -> tuple[RelationshipEntry, ...]: - entries: dict[ThreadIncarnation, RelationshipEntry] = {} - for relation in (*context.edges, *context.goal_contacts): - identity = relation.counterpart(context.thread) - if identity is None: - continue - person = context.people.get(identity.name) - available = person is not None and person.thread.incarnation == identity - entries[identity] = relation.entry(identity, person, available, entries.get(identity)) - return tuple(entries.values()) + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selected: dict[ThreadIncarnation, list[MutualThreadRelation]] = {} + for relation in (*inputs.edges, *inputs.goal_contacts): + for identity in relation.counterparts(inputs.thread): + selected.setdefault(identity, []).append(relation) + return tuple(MutualEntrySource(identity, tuple(relations)) + for identity, relations in selected.items()) @dataclass(frozen=True, slots=True) -class RelationshipContext: +class RelationshipInputs: + """One captured selection basis, before acquiring any person presentation.""" + registry: RegistrySnapshot thread: Thread - people: dict[str, presentation.ThreadView] - messages: tuple[Message, ...] + messages: tuple[MessageInput, ...] delivery: DeliveryScope edges: tuple[Collaboration, ...] goal_contacts: tuple[GoalDerivedContact, ...] - orders: dict[type[SortableRelationshipGroup], type[ThreadSort]] - sent: dict[str, float] + orders: Mapping[type[SortableRelationshipGroup], type[ThreadSort]] + sent: Mapping[str, float] + - def entry(self, name: str, *, message: Message | None = None, +@dataclass(frozen=True, slots=True) +class RelationshipContext(RelationshipInputs): + people: Mapping[str, presentation.ThreadView] + + def entry(self, target: NamedTarget, *, sequence: int = 0, timestamp: float = 0, detail: str = "") -> RelationshipEntry: - target = CommandTarget.named(name) - name = target.canonical_name(self.registry.canonical_name) return RelationshipEntry( - name, target.kind, self.people.get(name), - message.seq if message else 0, message.timestamp if message else 0, detail, + target.name, target.kind, self.people.get(target.name, presentation.NoThreadView()), + sequence, timestamp, detail, + ) + + +class RelationshipEntrySource(ABC): + """An original group input, never an unfinished public entry or a wire record.""" + + __slots__ = () + + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def entry(self, context: RelationshipContext) -> RelationshipEntry: ... + + @staticmethod + def canonical_target(name: str, registry: RegistrySnapshot) -> NamedTarget: + target = CommandTarget.named(name) + return replace(target, name=target.selected_name(registry)) + + +@dataclass(frozen=True, slots=True) +class NamedEntrySource(RelationshipEntrySource): + target: NamedTarget + + @property + def name(self) -> str: + return self.target.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry(self.target) + + +@dataclass(frozen=True, slots=True) +class RecentEntrySource(NamedEntrySource): + message: MessageInput + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + return context.entry( + self.target, sequence=self.message.seq, timestamp=self.message.timestamp, + detail=f"{self.message.sender} → {self.message.target}\n{self.message.body[:240]}", ) +@dataclass(frozen=True, slots=True) +class MutualEntrySource(RelationshipEntrySource): + identity: ThreadIncarnation + relations: tuple[MutualThreadRelation, ...] + + @property + def name(self) -> str: + return self.identity.name + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + person = context.people.get(self.name, presentation.NoThreadView()) + available = any(view.thread.incarnation == self.identity for view in person.views()) + first, *remaining = self.relations + entry = first.entry(self.identity, person, available, None) + for relation in remaining: + entry = relation.entry(self.identity, person, available, entry) + return entry + + @dataclass(frozen=True, slots=True) class ThreadCommsSnapshot: owner: str @@ -557,7 +640,7 @@ def active(self) -> Collaboration | None: edge for edge in self.pairs if self.second is not None - and edge.counterpart(self.first) == self.second.incarnation + and self.second.incarnation in edge.counterparts(self.first) ), None, ) @@ -712,13 +795,11 @@ def _goal_contacts( active goals in ONE registry snapshot; never changes an explicit contact or delivery.""" contacts: list[GoalDerivedContact] = [] diagnostics: list[GoalMentionDiagnostic] = [] - for owner in ( - executor - for thread in registry.threads.values() - for executor in thread.role.executor(thread) - ): + for owner in registry.threads.active_goal_owners(): for goal in owner.goal.active(): - ThreadRelationships._project_goal_contacts(goal, owner, registry, contacts, diagnostics) + ThreadRelationships._project_goal_contacts( + goal, owner, registry, contacts, diagnostics, + ) return (tuple(row for row in contacts if row.incident(thread)), tuple(row for row in diagnostics if row.owner == thread.name)) @@ -757,10 +838,10 @@ def peer_relations(self, owner: str) -> tuple[PeerRelation, ...]: granted[peer.incarnation].append(ChannelmateRelevance) relation: MutualThreadRelation for relation in (*edges, *contacts): - identity = relation.counterpart(thread) - if identity in granted: - granted[identity].append(relation.relevance) - records.setdefault(identity, []).append(relation.record(identity)) + for identity in relation.counterparts(thread): + if identity in granted: + granted[identity].append(relation.relevance) + records.setdefault(identity, []).append(relation.record(identity)) return tuple(sorted( ( PeerRelation( @@ -807,10 +888,11 @@ def contacts(self, owner: str) -> Contacts: contacts, diagnostics = self._goal_contacts(registry, thread) visible: dict[ThreadIncarnation, RelationshipEntry] = {} for relation in (*explicit, *contacts): - identity = relation.counterpart(thread) - visible[identity] = relation.entry( - identity, None, identity.current(registry), visible.get(identity), - ) + for identity in relation.counterparts(thread): + visible[identity] = relation.entry( + identity, presentation.NoThreadView(), identity.current(registry), + visible.get(identity), + ) return Contacts( explicit, tuple(sorted(visible.values(), key=lambda row: (row.target, not row.available))), @@ -846,11 +928,14 @@ def set_order(self, owner: str, group: type[SortableRelationshipGroup], ) return order - def _recent_messages(self) -> tuple[tuple[Message, ...], bool]: + def _recent_messages(self) -> tuple[tuple[MessageInput, ...], bool]: """One bounded sequence page, on a single committed read snapshot.""" with self.bus.bus.reading() as source: - messages = source.page(source.revision() + 1, self.RECENT_MESSAGES + 1) - return tuple(reversed(messages[:self.RECENT_MESSAGES])), len(messages) > self.RECENT_MESSAGES + messages = source.input_page(source.revision() + 1, self.RECENT_MESSAGES + 1) + return ( + tuple(reversed(messages[:self.RECENT_MESSAGES])), + len(messages) > self.RECENT_MESSAGES, + ) def snapshot(self, owner: str) -> ThreadCommsSnapshot: # One coherent identity/metadata basis; wire payload work is bounded. @@ -860,26 +945,25 @@ def snapshot(self, owner: str) -> ThreadCommsSnapshot: edges = state.collaborations goal_contacts, goal_diagnostics = self._goal_contacts(registry, thread) delivery = DeliveryScope.capture(thread.name, registry, self.views.channels.catalog.read()) - people = { - view.thread.name: view - for view in self.views.thread_views(RosterVisibility(archived=True)) - } messages, limited = self._recent_messages() - context = RelationshipContext( - registry, thread, people, messages, delivery, edges, goal_contacts, - {row.group: row.order for row in state.orders if row.incarnation == thread.incarnation}, - {}, - ) + orders = { + row.group: row.order for row in state.orders if row.incarnation == thread.incarnation + } + sent: dict[str, float] = {} for order in { - group.selected_order(context) + group.selected_order(orders) for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup) }: - context.sent.update(order.sent_timestamps(self.views, registry=registry)) + sent.update(order.sent_timestamps(self.views, registry=registry)) + inputs = RelationshipInputs( + registry, thread, messages, delivery, edges, goal_contacts, + MappingProxyType(orders), MappingProxyType(sent), + ) return ThreadCommsSnapshot( thread.name, str(self.root.resolve()), - tuple(group.build(context) for group in RelationshipGroup.members_with(RelationshipGroup)), + RelationshipGroup.capture(inputs, self.views), limited, len(messages), unresolved_goal_mentions=goal_diagnostics, diff --git a/src/agent_comms/thread_creation.py b/src/agent_comms/thread_creation.py index 693f37487d..d66fe6a19e 100644 --- a/src/agent_comms/thread_creation.py +++ b/src/agent_comms/thread_creation.py @@ -6,11 +6,12 @@ import time from abc import ABC, abstractmethod from collections.abc import Set +from typing import Annotated, TypeAlias from metaclass_registry.mro_dispatch import handles from .errors import RelationViolationError -from .field_codec import FieldCodec, FieldRepresentation, WireValue +from .field_codec import FieldCodec, FieldRepresentation, WireRequired, WireValue class CreationTime(float, WireValue, ABC): @@ -67,3 +68,6 @@ def encode(cls, value: CreationTime) -> float: @classmethod def schema(cls) -> dict[str, object]: return CreationTime.schema() + + +RequiredCreationTime: TypeAlias = Annotated[CreationTime, CreationSeconds, WireRequired()] diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 33b5bde8fa..fcbf345882 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -6,7 +6,10 @@ from __future__ import annotations +from .thread_name import ThreadName + from .class_fact import ClassFact +from .thread_creation import CreationSeconds, CreationTime, RequiredCreationTime import hashlib import math from abc import ABC, abstractmethod @@ -24,7 +27,7 @@ UnknownFamilyMember, declared_words, ) -from .errors import RelationViolationError +from .errors import RelationViolationError, UnregisteredThreadError from .field_codec import ( DisclosureIdentity, FieldCodec, @@ -191,7 +194,7 @@ def schema(cls) -> dict: @classmethod def from_wire(cls, data: dict) -> ThreadIncarnation: - return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation() + return FieldCodec.decode(ThreadIncarnationRecord, data).incarnation @classmethod def deleted(cls, name: str) -> DeletedThreadIncarnation: @@ -209,15 +212,24 @@ def require_recorded(self) -> RecordedThreadIncarnation: def renamed(self, name: str) -> ThreadIncarnation: """Retain this member's birth state under an original alias.""" + def require_current(self, snapshot: RegistryProvenance) -> ThreadIncarnation: + """Require this original birth and return its current canonical incarnation.""" + original = self.require_recorded() + current = snapshot.incarnation(original.name) + if not original.current(snapshot): + raise UnregisteredThreadError( + f"Thread {original.name!r} no longer has its recorded birth." + ) + return current + def resolved(self, snapshot: RegistryProvenance) -> ThreadIncarnation: """Follow retained rename aliases only for this exact historical owner.""" if not self.current(snapshot): return self - return snapshot.threads[snapshot.canonical_name(self.name)].incarnation + return snapshot.incarnation(self.name) def current(self, snapshot: RegistryProvenance) -> bool: - thread = snapshot.threads.get(snapshot.canonical_name(self.name)) - return thread is not None and thread.incarnation == self + return snapshot.registered(self.name) and snapshot.incarnation(self.name) == self def recorded_or( self, make: Callable[[RecordedThreadIncarnation], T], otherwise: Callable[[], T] @@ -255,8 +267,7 @@ def matches_recorded_name(self, name: str, snapshot: RegistryProvenance) -> bool The registry owns aliases and birth. A stored name does not supply or manufacture either fact, including after a name is reused. """ - thread = snapshot.threads.get(snapshot.canonical_name(name)) - return thread is not None and self.resolved(snapshot) == thread.incarnation + return snapshot.registered(name) and self.resolved(snapshot) == snapshot.incarnation(name) def recorded_names(self, snapshot: RegistryProvenance) -> tuple[ThreadIncarnation, ...]: """Original names retained for this exact current incarnation.""" @@ -434,25 +445,47 @@ def recorded_as(cls, name: str) -> CoreWatcherIncarnation: return cls.watcher() -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True) class ThreadIncarnationRecord: """The single exact external name/birth record; members own its meaning.""" name: str created_at: float + def __post_init__(self) -> None: + ThreadName.require(self.name) + + @property + def birth(self) -> float: + """The external identity clock; float records already carry its wire value.""" + return self.created_at + + @cached_property def incarnation(self) -> ThreadIncarnation: + """One birth derivation, shared by full declarations and captured narrow rows.""" + birth = self.birth try: - member = BirthlessIncarnation.member_selected(self.created_at) + member = BirthlessIncarnation.member_selected(birth) except UnknownFamilyMember: - return RecordedThreadIncarnation(self.name, self.created_at) + return RecordedThreadIncarnation(self.name, birth) return member.recorded_as(self.name) - @classmethod - def decode(cls, name: str, birth: float) -> ThreadIncarnation: - return FieldCodec.decode(cls, {"name": name, "created_at": birth}).incarnation() +@dataclass(frozen=True, kw_only=True) +class ThreadBirthRecord(ThreadIncarnationRecord): + """A registered declaration retains its recorded/generated creation-clock capability.""" + + created_at: RequiredCreationTime + @property + def birth(self) -> float: + return self.created_at.to_wire() + + def __post_init__(self) -> None: + object.__setattr__(self, "created_at", FieldCodec.decode( + Annotated[CreationTime, CreationSeconds], self.created_at, + )) + super().__post_init__() @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/thread_membership.py b/src/agent_comms/thread_membership.py new file mode 100644 index 0000000000..65e694b58d --- /dev/null +++ b/src/agent_comms/thread_membership.py @@ -0,0 +1,26 @@ +"""The captured thread identity and its declared channel membership.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Annotated + +from .channel_targets import Tag +from .field_codec import RegistrationPolicy +from .registration_inheritance import InheritEmpty +from .thread_identity import ThreadBirthRecord + + +@dataclass(frozen=True, kw_only=True) +class ThreadMembership(ThreadBirthRecord): + """The original thread identity and its declared channel tags.""" + + tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) + + def carries(self, tags: frozenset[str]) -> bool: + return tags <= self.tags + + def __post_init__(self) -> None: + super().__post_init__() + for tag in self.tags: + Tag.validate(tag) diff --git a/src/agent_comms/thread_name.py b/src/agent_comms/thread_name.py new file mode 100644 index 0000000000..65c4f3f251 --- /dev/null +++ b/src/agent_comms/thread_name.py @@ -0,0 +1,14 @@ +"""The spelling rule shared by thread declarations and addressed targets.""" + +from typing import ClassVar + + +class ThreadName: + characters: ClassVar[frozenset[str]] = frozenset( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + ) + + @classmethod + def require(cls, name: str) -> None: + if not name or not set(name) <= cls.characters: + raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") diff --git a/src/agent_comms/threads.py b/src/agent_comms/threads.py index ebcbd7c4ac..01f57570ba 100644 --- a/src/agent_comms/threads.py +++ b/src/agent_comms/threads.py @@ -4,13 +4,14 @@ import os from abc import ABC, abstractmethod +from collections.abc import Iterator from dataclasses import dataclass, field, fields, replace from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Annotated, ClassVar from .agent_backend import AgentBackend, NewThreadBackend, RuntimeLocation -from .channel_targets import CommandersTag, DeclaredTag, Tag +from .channel_targets import CommandersTag, DeclaredTag from .child_process import NoProcess, ProcessIdentity, ProcessState from .context_segments.wake_values import WakeWorkContext from .declared_family import DeclaredFamily @@ -19,6 +20,7 @@ Argument, Description, FieldCodec, + JsonValue, NullMember, RegistrationPolicy, SingleFieldValue, @@ -28,6 +30,7 @@ WireRequired, ) from .goals import ( + Goal, GoalDeclaration, NoGoal, ) @@ -43,25 +46,25 @@ ) from .registration_inheritance import ( InheritApplied, - InheritEmpty, InheritPrevious, ) from .repo import Checkout from .session_file import NoSessionFile, SessionFile from .task_sources import TaskScope -from .thread_creation import CreationSeconds, CreationTime, GeneratedCreationTime +from .thread_creation import GeneratedCreationTime, RequiredCreationTime from .thread_execution import NativeThreadExecution, ThreadExecution from .thread_identity import ( AgentThreadRole, ObservedTurn, OfferedTools, + ThreadBirthRecord, ThreadIncarnation, - ThreadIncarnationRecord, ThreadRole, TurnFence, TurnIdentity, UnobservedTools, ) +from .thread_membership import ThreadMembership from .thread_model import ( CatalogSelection, DeclaredModel, @@ -187,13 +190,10 @@ def configured(self, thread: Thread) -> Thread: @dataclass(frozen=True, kw_only=True) -class ThreadProvenance: +class ThreadProvenance(ThreadMembership): """Recorded thread identity, without permission to own a process or start a turn.""" - name: str = field(kw_only=False) - tags: Annotated[frozenset[str], RegistrationPolicy(InheritEmpty)] = field(kw_only=False) worktree: Checkout = field(kw_only=False) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] session_file: Annotated[SessionFile, RegistrationPolicy(InheritApplied)] = field( default_factory=NoSessionFile ) @@ -218,19 +218,6 @@ def writable_runtime(self) -> RuntimeLocation: def display_title(self) -> str: return self.title.otherwise(self.name) - @staticmethod - def require_name(name: str) -> None: - allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") - if not name or not set(name) <= allowed: - raise ValueError(f"Thread name {name!r} must be alphanumeric with hyphens/underscores.") - - def __post_init__(self) -> None: - object.__setattr__(self, "created_at", FieldCodec.decode( - Annotated[CreationTime, CreationSeconds], self.created_at)) - self.require_name(self.incarnation.name) - for tag in self.tags: - Tag.validate(tag) - @staticmethod def capture(thread: ThreadProvenance) -> ThreadProvenance: """Freeze only determining provenance from the original typed declaration.""" @@ -242,11 +229,6 @@ def capture(thread: ThreadProvenance) -> ThreadProvenance: } ) - @cached_property - def incarnation(self) -> ThreadIncarnation: - """The declaration's birth member, created once and shared by its readers.""" - return ThreadIncarnationRecord(self.name, self.created_at.to_wire()).incarnation() - @property def acting_identity(self) -> ThreadIncarnation: """Who this thread's own tool calls act as: a #commanders member holds commander @@ -264,8 +246,21 @@ class ThreadCatalogFile(DurableFile, PatternFile): filename: ClassVar[str] = "native-sessions/{lookup}/comms-catalog.json" +@dataclass(frozen=True, kw_only=True) +class GoalOwner(ThreadBirthRecord): + """The declared goal and executor membership, shared by full and captured rows.""" + + goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( + field(default_factory=NoGoal) + ) + role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = AgentThreadRole + + def active_goals(self) -> Iterator[Goal]: + return (goal for owner in self.role.executor(self) for goal in owner.goal.active()) + + @dataclass(frozen=True, slots=True) -class Thread(ThreadProvenance, CatalogSelection): +class Thread(ThreadProvenance, GoalOwner, CatalogSelection): """Declares one agent thread's identity and provenance.""" def answering_thread(self, reader: Thread) -> Thread: @@ -289,10 +284,7 @@ def answering_thread(self, reader: Thread) -> Thread: service_tier: Annotated[ServiceTier, RegistrationPolicy(InheritPrevious)] = ( field(default_factory=ServiceTier.default, kw_only=True) ) - goal: Annotated[GoalDeclaration, RegistrationPolicy(GoalDeclaration)] = ( - field(default_factory=NoGoal) - ) - created_at: Annotated[CreationTime, CreationSeconds, WireRequired()] = field( + created_at: RequiredCreationTime = field( default_factory=GeneratedCreationTime.now, kw_only=True ) storage_roots: Annotated[ @@ -313,9 +305,6 @@ def answering_thread(self, reader: Thread) -> Thread: project_resume_due: Annotated[ bool, RegistrationPolicy(InheritPrevious), WireOmitDefault() ] = False - role: Annotated[type[ThreadRole], RegistrationPolicy(InheritPrevious)] = ( - AgentThreadRole - ) execution: Annotated[type[ThreadExecution], WireOmitDefault()] = field( default=NativeThreadExecution, kw_only=True ) @@ -655,7 +644,7 @@ def turn_started_by(self, updated_at_ms: int) -> bool: def executing(self) -> bool: return bool(self.turn_record.items_with(Executing, self)) - def to_wire(self) -> dict[str, object]: + def to_wire(self) -> dict[str, JsonValue]: """Schema-derived projection at a JSON boundary, not a hand-maintained mirror.""" return FieldCodec.encode(self) diff --git a/tests/c4_coordination_installed_ui.py b/tests/c4_coordination_installed_ui.py index 513345022c..03ac4abca1 100644 --- a/tests/c4_coordination_installed_ui.py +++ b/tests/c4_coordination_installed_ui.py @@ -61,7 +61,10 @@ async def main(): assert original.outcomes[message.seq, message.message_id] assert comms.views.message_notifications_for_references(AllOutcomes(), (message.reference,) * (NotificationWindow.window_limit + 1)).outcomes == original.outcomes - page = comms.views.channel_display_page('#team', viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(root)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(root)) assert comms.views.viewer_snapshot(Checkout(root)).channel_unread['#team'].count == 0 assert comms.bus.pending_count('stopped', CommandTarget.named('#team')) == 1 diff --git a/tests/test_bus.py b/tests/test_bus.py index c79ba6849a..80e1e3b952 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -38,6 +38,7 @@ InfoKind, MembershipTransition, Message, + MessageInput, MessageKind, MessageRoleWire, MessageRoutes, @@ -50,7 +51,7 @@ RelationViolationError, UnclaimedError, ) -from agent_comms.field_codec import GitCommitText, MessageId, MessageIdText +from agent_comms.field_codec import FieldCodec, GitCommitText, MalformedRecord, MessageId, MessageIdText from agent_comms.message_reference import MessageReference from agent_comms.read_basis import RepliesDisplayScope from agent_comms.response_acknowledgement import ( @@ -123,8 +124,8 @@ def edit_inside( module = ast.parse(path.read_text()) declaration, = (node for node in module.body if isinstance(node, ast.ClassDef) and node.name == owner.__name__) - statement, = (node for node in declaration.body - if member in ProducedTable.bound_names(ast.unparse(node))) + statement = next(node for node in reversed(declaration.body) + if member in ProducedTable.bound_names(ast.unparse(node))) change(statement) path.write_text(ast.unparse(ast.fix_missing_locations(module)) + "\n") @@ -134,6 +135,11 @@ def return_no_rows(statement: ast.stmt) -> None: statement.body = [ast.Return(value=ast.Tuple(elts=[], ctx=ast.Load()))] +def return_inherited_routes(statement: ast.stmt) -> None: + assert isinstance(statement, ast.FunctionDef) + statement.body = ast.parse("return super().route_rows(message)").body + + def widen_spelling(statement: ast.stmt) -> None: assert isinstance(statement, ast.Assign) statement.value = ast.BinOp(left=statement.value, op=ast.Add(), right=ast.Constant(1)) @@ -246,12 +252,15 @@ def test_projection_digest_tracks_actual_delivery_production(projection_source: assert after[CommanderUpdatePosition.declared_name] == before[CommanderUpdatePosition.declared_name] -@pytest.mark.parametrize("owner", (MessageRoutes, ClaimNoticeKind)) +@pytest.mark.parametrize("owner, change", ( + (MessageRoutes, return_no_rows), + (ClaimNoticeKind, return_inherited_routes), +)) def test_projection_digest_tracks_inherited_routing( - projection_source: Path, owner: type, + projection_source: Path, owner: type, change: Callable[[ast.stmt], None], ) -> None: before = json.loads(projection_source_digest(projection_source)) - edit_inside(projection_source, owner, "route_rows", return_no_rows) + edit_inside(projection_source, owner, "route_rows", change) after = json.loads(projection_source_digest(projection_source)) assert after[BusRouteCounts.declared_name] != before[BusRouteCounts.declared_name] assert after[AnsweredSource.declared_name] == before[AnsweredSource.declared_name] @@ -392,6 +401,24 @@ def test_real_message_record_round_trip(bus, declaration, role): assert bus.lookup(stored.seq) == stored == record assert Bus(bus.path).lookup(stored.seq) == stored assert bus.page(stored.seq + 1, 10) == (stored,) + expected_input = FieldCodec.construct(MessageInput, { + entry.name: entry.current(stored) for entry in FieldCodec.field_plans(MessageInput) + }) + with bus.reading() as snapshot: + with snapshot.observing() as db: + allowed = {table.declared_name for table in MessageInput.tables()} | {BusRow.declared_name} + + def input_reads_only(action, table, column, database, trigger): + return (sqlite3.SQLITE_DENY if action == sqlite3.SQLITE_READ and table not in allowed + else sqlite3.SQLITE_OK) + + db.set_authorizer(input_reads_only) + try: + assert snapshot.input_page(stored.seq + 1, 10) == (expected_input,) + with pytest.raises(sqlite3.DatabaseError): + snapshot.page(stored.seq + 1, 10) + finally: + db.set_authorizer(None) assert stored.message is stored assert stored.publication_snapshot == message.publication_snapshot assert type(stored.publication_snapshot) is Message @@ -432,8 +459,11 @@ def test_record_family_rebuild_and_history_install_preserve_scope(bus, declarati assert bus.lookup_message(record.message_id) == record assert bus.page(record.seq + 1, 10) == (record, original) scope = RepliesDisplayScope(frozenset({"author"}), frozenset({"worker"})) + history = scope.history(bus, limit=2) + assert history == (original, record) + assert tuple(type(item) for item in history) == (type(original), declaration) copied = Bus.install( - Bus.path_in(tmp_path / "selected-history"), history=scope.history(bus, limit=2), + Bus.path_in(tmp_path / "selected-history"), history=history, ) kept = copied.lookup(record.seq) assert kept == record.history_record @@ -556,9 +586,12 @@ def test_scoped_and_frozen_source_queries_are_sequence_bounded(bus): Message("author", "worker", "notice", ClaimHistoryKind, seq=seq))) reply = bus.append(lambda seq, db: record_from(PrivateRecord, Message("author", "worker", "reply", InfoKind, seq=seq, answers=(original.reference,)))) - assert bus.scoped_page("target=?", ("worker",), descending=True, limit=1) == (reply,) - assert bus.scoped_page("target=?", ("worker",), upper=reply.seq, limit=1) == (notice,) - assert bus.scoped_page("target=?", ("worker",), kinds=(InfoKind,), limit=2) == (reply,) + assert bus.scoped_page("target=?", ("worker",), descending=True, limit=1) == ( + Message.from_message(reply),) + assert bus.scoped_page("target=?", ("worker",), upper=reply.seq, limit=1) == ( + Message.from_message(notice),) + assert bus.scoped_page("target=?", ("worker",), kinds=(InfoKind,), limit=2) == ( + Message.from_message(reply),) assert bus.conversation("b" * 32, limit=2) == (reply, notice) assert bus.sent("a" * 32, limit=1) == (reply,) assert bus.direct_replies("b" * 32, after=original.seq, limit=1, descending=False, @@ -722,6 +755,19 @@ def build(seq, db): assert all(table.count(db) == 0 for table in TypedTable.members_with(BusTables)) +def test_required_capability_corruption_is_not_hidden_by_the_join(bus): + with bus.writing() as db: + # A retained header without its required capability rows is corruption, + # not an empty page or an absent optional field. + BusRow(1, PublicRecord, 1.0).insert(db) + with pytest.raises(RelationViolationError, match="malformed required Message capability") as refused: + Message.read_from(db, 1) + assert isinstance(refused.value.__cause__, MalformedRecord) + with pytest.raises(RelationViolationError) as history_refused: + tuple(BusCapability.history(db)) + assert isinstance(history_refused.value.__cause__, MalformedRecord) + + def test_bulk_history_keeps_one_snapshot_across_pages(bus): assert tuple(bus.message_history()) == () originals = tuple(Message.from_message(bus.append(lambda seq, db: PublicRecord.from_message( diff --git a/tests/test_bus_activity.py b/tests/test_bus_activity.py index d8687297cd..8b27186110 100644 --- a/tests/test_bus_activity.py +++ b/tests/test_bus_activity.py @@ -8,7 +8,7 @@ from agent_comms.mentions import ThreadMention from agent_comms.messages import InfoKind, Message, NoticeRole from agent_comms.read_basis import ( - AllChannelDisplayScope, ChannelDisplayScope, MemberActivityDisplayScope, + AllChannelDisplayScope, ChannelDisplayScope, NamedActivityDisplayScope, ) from agent_comms.thread_identity import UserThreadRole @@ -32,12 +32,12 @@ def test_activity_family_tracks_original_roles_targets_and_member_scope(bus): scopes = ( (ChannelDisplayScope("#team", frozenset({"#team"})), ChannelActivity(30.0, 0.0)), (AllChannelDisplayScope("#any"), ChannelActivity(30.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"mentioned"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"mentioned"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", participant_names=frozenset({"worker"})), + (NamedActivityDisplayScope("#member", participants=frozenset({"worker"})), ChannelActivity(20.0, 20.0)), - (MemberActivityDisplayScope("#member", frozenset({"#team"}), - participant_names=frozenset({"human"})), + (NamedActivityDisplayScope("#member", frozenset({"#team"}), + participants=frozenset({"human"})), ChannelActivity(30.0, 20.0)), ) with bus.reading() as captured, captured.observing() as db: diff --git a/tests/test_channel_display.py b/tests/test_channel_display.py index 0e4a9a3a7f..f90298db9c 100644 --- a/tests/test_channel_display.py +++ b/tests/test_channel_display.py @@ -61,12 +61,12 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): before_delivery = comms.bus.pending_counts_opened(source, basis) before_bus = comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] selected = comms.channels.set_channel_any_mode("#api", True) assert selected.exact and selected.any_mode assert wire(tmp_path).bus.require_channel("#api").any_mode - page = comms.views.channel_display_page("#api") + page = comms.views.channel_display_view("#api").page(comms.views.presentation) assert bodies(page) == [ "exact target", "member to outsider", @@ -77,12 +77,14 @@ def test_default_off_and_opt_in_only_changes_display(tmp_path): assert len({message.seq for message in page.messages}) == len(page.messages) assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == ["exact target"] assert [message.body for message in list(comms.views.history_page(CommandTarget.named("#api")).messages)] == ["exact target"] - assert bodies(comms.views.channel_display_page("#ui")) == ["unrelated channel"] + assert bodies(comms.views.channel_display_view( + '#ui', + ).page(comms.views.presentation)) == ["unrelated channel"] assert comms.bus.bus.page(comms.bus.bus.revision() + 1, 100) == before_bus with comms.bus.snapshot(viewer=NamedViewer("alice")) as (basis, source): assert comms.bus.pending_counts_opened(source, basis) == before_delivery assert not comms.channels.set_channel_any_mode("api", False).any_mode - assert bodies(comms.views.channel_display_page("#api")) == ["exact target"] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == ["exact target"] def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(tmp_path): @@ -102,7 +104,7 @@ def test_mode_rejects_builtins_union_view_and_unknown_without_sidecar_mutation(t comms.channels.set_channel_any_mode("#api", 1) assert (metadata.read_bytes() if metadata.exists() else None) == before with pytest.raises(ValueError): - comms.views.channel_display_page("#missing") + comms.views.channel_display_view("#missing").page(comms.views.presentation) def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): @@ -114,7 +116,9 @@ def test_opt_in_user_input_activity_uses_same_member_scope(tmp_path): on = comms.views.viewer_snapshot(Checkout(tmp_path)).channels["#api"] assert on.last_user_input > 0 assert comms.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#api"].count == 0 - assert bodies(comms.views.channel_display_page("#api")) == ["human input to member"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["human input to member"] assert bodies(comms.views.history_page(CommandTarget.named("#api"))) == [] @@ -123,19 +127,23 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): comms.messaging.send("alice", "bob", "old sender DM").message.message_id comms.channels.set_channel_any_mode("api", True) high_water = comms.bus.bus.revision() - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] initial = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert initial["#api"].last_activity > 0 comms.registry.rename("alice", "renamed") assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation)) == ["old sender DM"] renamed = comms.registry.require("renamed") comms.registry.register( replace(renamed, tags=frozenset({"ui"})), comms.registry.status("renamed") ) assert comms.bus.bus.revision() == high_water - assert bodies(comms.views.channel_display_page("#api")) == [] + assert bodies(comms.views.channel_display_view("#api").page(comms.views.presentation)) == [] after = comms.views.viewer_snapshot(Checkout(tmp_path)).channels assert after["#api"].last_activity < initial["#api"].last_activity @@ -145,7 +153,9 @@ def test_alias_retag_and_tag_rename_reproject_without_new_wire_row(tmp_path): assert comms.bus.require_channel("#services").any_mode with pytest.raises(ValueError): comms.bus.require_channel("#api") - assert bodies(comms.views.channel_display_page("#services")) == ["old sender DM"] + assert bodies(comms.views.channel_display_view( + '#services', + ).page(comms.views.presentation)) == ["old sender DM"] comms.channels.delete_tag("services") metadata = comms.channels.catalog.path assert not any( @@ -181,10 +191,14 @@ def test_page_boundaries_match_target_pager(tmp_path): comms.messaging.send("outsider", "#api", "second target").message.message_id comms.messaging.send("bob", "alice", "third projected").message.message_id comms.channels.set_channel_any_mode("#api", True) - rows = comms.views.channel_display_page("#api").messages - head = comms.views.channel_display_page("#api", bounds=PageBounds(NewerTraversal(), 1)) + rows = comms.views.channel_display_view("#api").page(comms.views.presentation).messages + head = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(NewerTraversal(), 1)) assert head.has_newer and not head.has_older - tail = comms.views.channel_display_page("#api", bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) + tail = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(rows[-1].seq).older(), 1)) assert bodies(tail) == ["second target"] and tail.has_older and tail.has_newer @@ -212,16 +226,25 @@ def burst(): assert started.wait(timeout=3) try: for _ in range(20): - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) if page.newest_seq is not None: comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - dm = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + dm = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert bodies(dm) == ["DM original"] comms.views.mark_page_read(dm, worktree=Checkout(tmp_path)) finally: writer.join(timeout=10) assert finished.is_set() and not writer.is_alive() and not failures - page = comms.views.channel_display_page("#api", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#api', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert bodies(page) == [f"burst {n}" for n in range(20)] comms.registry.rename("alice", "renamed") with pytest.raises(DMScopeChanged): @@ -234,5 +257,7 @@ def test_views_answer_how_far_the_bus_has_grown(tmp_path): comms.messaging.send("outsider", "#api", "to a channel").message.message_id comms.messaging.send("alice", "bob", "to someone else").message.message_id # Every message counts, not only those one conversation shows. - newest_in_channel = comms.views.channel_display_page("#api").messages[-1].seq + newest_in_channel = comms.views.channel_display_view( + '#api', + ).page(comms.views.presentation).messages[-1].seq assert comms.views.latest_sequence() == comms.bus.bus.revision() > newest_in_channel diff --git a/tests/test_channel_retirement.py b/tests/test_channel_retirement.py index 053d0a5017..5b79e120c1 100644 --- a/tests/test_channel_retirement.py +++ b/tests/test_channel_retirement.py @@ -74,9 +74,13 @@ def test_saved_projection_preserves_preferences_history_and_has_no_writable_alia assert set( next(v.members for v in comms.views.channel_views() if v.channel.name == "#engineering") ) == {"a", "b"} - tail = comms.views.channel_display_page("#engineering", bounds=PageBounds(limit=2)) + tail = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) assert [m.seq for m in tail.messages] == [3, 4] - older = comms.views.channel_display_page("#engineering", bounds=PageBounds(LiveCursor(3).older(), 2)) + older = comms.views.channel_display_view( + '#engineering', + ).page(comms.views.presentation, bounds=PageBounds(LiveCursor(3).older(), 2)) assert [m.seq for m in older.messages] == [1, 2] assert [m.target for m in read_history(comms, "#engineering")] == [ m.target for m in rows diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index bc78c6914c..540f1e0fb7 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -218,7 +218,7 @@ def whole_bus(*_, **__): assert ConversationMessagePageRequest(dm_scope).read_opened(comms.bus.bus).messages == () raw_channel = comms.views.history_page(CommandTarget.named("#team")).messages assert [message.body for message in raw_channel] == ["@Alice investigate"] - channel = comms.views.channel_display_page("#team").messages + channel = comms.views.channel_display_view("#team").page(comms.views.presentation).messages assert [message.body for message in channel] == ["@Alice investigate"] assert all(message.role.notification_references(message) for message in channel) with pytest.raises(ValueError): diff --git a/tests/test_current_delivery.py b/tests/test_current_delivery.py index 48d1feac6d..3ab48cf5b8 100644 --- a/tests/test_current_delivery.py +++ b/tests/test_current_delivery.py @@ -54,7 +54,7 @@ def test_current_inbox_bulk_and_index_exclude_rebound_history(routed, rebound): with current.bus.snapshot(viewer=NamedViewer("recipient")) as (basis, source): assert current.bus.pending_counts_opened(source, basis) == {"sender": 1, "#team": 1} assert [row.seq for row in current.bus.inbox("recipient", CommandTarget.named("sender")).page.messages] == [new.seq] - assert old in current.views.channel_display_page("#any").messages + assert old in current.views.channel_display_view("#any").page(current.views.presentation).messages assert [row.body for row in current.views.history_page( CommandTarget.named("recipient"), viewer=NamedViewer("sender"), ).messages] == ["retained history", "current input"] diff --git a/tests/test_display_unread.py b/tests/test_display_unread.py index 1d47bb85dd..d853a49466 100644 --- a/tests/test_display_unread.py +++ b/tests/test_display_unread.py @@ -1,8 +1,10 @@ from dataclasses import replace +import pytest + from agent_comms.audience_manifest import FrozenRecipient from agent_comms.bus import Bus, DisplayUnread -from agent_comms.field_codec import ThreadLookup +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup from agent_comms.messages import InfoKind, Message, PublicRecord from agent_comms.notification_progress import UnrecordedProgress from agent_comms.presentation import MessageNotification @@ -11,9 +13,15 @@ ChannelDisplayScope, DisplayedConversation, DisplayUnreadSelection, + MemberActivityDisplayScope, ReadCursor, ) +from agent_comms.registry_document import ThreadRows from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.repo import Checkout +from agent_comms.thread_status import RunningThreadStatus +from agent_comms.thread_text import GivenThreadText +from agent_comms.threads import Thread from agent_comms.thread_presentation import ( ThreadPresentation, WorkingThreadPresentationStatus, @@ -120,6 +128,95 @@ class RevisedRegistry(RegistryProvenance): assert fourth == source.display_unread(reread, through=source.revision()) +def test_tagged_unread_reuses_unchanged_membership_and_recounts_actual_tag_changes(comms, bus): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + other = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + for sender, target in ((member.name, other.name), (other.name, member.name)): + bus.append(lambda seq, db, sender=sender, target=target: PublicRecord.from_message( + Message(sender, target, "member activity", InfoKind, seq=seq))) + + def selection(snapshot): + return DisplayUnreadSelection( + ReadCursor(), (MemberActivityDisplayScope.capture(channel, snapshot),), + ("viewer",), snapshot, + ) + + original = selection(comms.registry.snapshot()) + with bus.reading() as source: + first = DisplayUnread().advanced(source, original) + assert first.counts == {"#team": 2} + comms.registry.update_thread( + other.name, lambda thread: replace(thread, task=GivenThreadText("unrelated edit")), + ) + revised = selection(comms.registry.snapshot()) + assert revised.queries() == original.queries() + assert revised.queries()[0].same_membership(original.queries()[0]) + statements = [] + with bus.reading() as source, source.observing() as db: + changes = db.total_changes + db.set_trace_callback(statements.append) + assert first.advanced(source, revised).counts == first.counts + assert not any(query.startswith("WITH read_cursors") for query in statements) + assert db.total_changes == changes + bus.append(lambda seq, db: PublicRecord.from_message( + Message(other.name, member.name, "appended activity", InfoKind, seq=seq))) + statements.clear() + with bus.reading() as source, source.observing() as db: + db.set_trace_callback(statements.append) + appended = first.advanced(source, revised) + counted = [query for query in statements if query.startswith("WITH read_cursors")] + assert counted and all(f"seq > {first.through}" in query for query in counted) + assert appended.counts == {"#team": 3} + assert appended == source.display_unread(revised, through=source.revision()) + comms.registry.update_thread(member.name, lambda thread: replace(thread, tags=frozenset())) + retagged = selection(comms.registry.snapshot()) + assert not retagged.queries()[0].same_membership(revised.queries()[0]) + with bus.reading() as source: + recounted = appended.advanced(source, retagged) + assert recounted.counts == {"#team": 0} + assert recounted == source.display_unread(retagged, through=source.revision()) + + +def test_tag_membership_keeps_alias_births_and_defers_unselected_full_fields(comms): + member = comms.registry.declare( + Thread("member", frozenset({"team"}), Checkout(comms.root.parent)), + status=RunningThreadStatus(), + ) + unrelated = comms.registry.declare(Thread("other", frozenset(), Checkout(comms.root.parent))) + channel = comms.bus.require_channel("#team") + before = comms.registry.snapshot() + original = MemberActivityDisplayScope.capture(channel, before) + comms.registry.rename(member.name, "renamed") + renamed = comms.registry.snapshot() + raw = renamed.threads.to_wire() + raw[unrelated.name][FieldCodec.field_plan(Thread, "model").key] = [] + narrow = replace(renamed, threads=ThreadRows.from_wire(raw)) + scope = MemberActivityDisplayScope.capture(channel, narrow) + assert scope.includes_participant(member.name) + assert scope.includes_participant("renamed") + assert scope.memberships[member.name].incarnation == member.incarnation + assert scope.memberships["renamed"].incarnation == member.incarnation + assert not scope.includes_participant(unrelated.name) + assert all("decoded" not in row.__dict__ for row in narrow.threads._rows.values()) + assert original.includes_participant(member.name) + comms.registry.unregister("renamed") + comms.threads.delete("renamed") + replacement = comms.registry.declare( + Thread(member.name, frozenset({"team"}), Checkout(comms.root.parent)), + ) + recreated = MemberActivityDisplayScope.capture(channel, comms.registry.snapshot()) + assert replacement.incarnation != member.incarnation + assert not recreated.same_membership(original) + raw["renamed"][FieldCodec.field_plan(Thread, "tags").key] = 7 + malformed = replace(renamed, threads=ThreadRows.from_wire(raw)) + with pytest.raises(MalformedRecord): + MemberActivityDisplayScope.capture(channel, malformed).memberships + + def test_status_row_equality_ignores_an_older_notification_outside_shown_lines(): lookup = ThreadLookup.mint() older = MessageNotification( diff --git a/tests/test_dm_scoped_ack.py b/tests/test_dm_scoped_ack.py index b663a6e4f7..cd9df558fb 100644 --- a/tests/test_dm_scoped_ack.py +++ b/tests/test_dm_scoped_ack.py @@ -30,7 +30,10 @@ def test_dm_page_marks_covered_range_preserving_omitted_older_unread(tmp_path: P viewer = comms.messaging.user_identity(Checkout(tmp_path)).name for index in range(50): comms.messaging.send("peer", viewer, f"old {index}").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=5)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=5)) assert len(page.messages) == 5 and page.has_older assert isinstance(page.selection, DMSelection) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -51,7 +54,10 @@ def test_peer_delete_same_name_rebind_rejects_stale_page_without_read_ack(tmp_pa comms.messaging.send("other", "#all", "OTHER_CHANNEL_4").message.message_id comms.messaging.send("peer", viewer, "PEER_PAINTED_5").message.message_id comms.messaging.send("other", viewer, "OTHER_UNPAINTED_6").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert [message.body for message in page.messages] == ["PEER_PAINTED_2", "PEER_PAINTED_5"] assert page.newest_seq == 5 and isinstance(page.selection, DMSelection) comms.registry.unregister("peer") @@ -75,7 +81,10 @@ def test_foreign_root_wrong_peer_and_unbounded_or_bool_through_rejected(tmp_path comms.registry.declare(_thread(comms.root, name)) viewer = comms.messaging.user_identity(Checkout(comms.root)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = first.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(first.root)) + page = first.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(first.root), + ).page(first.views.presentation) assert isinstance(page.selection, DMSelection) and page.newest_seq is not None for comms, target, through, error in ( (second, "peer", page.newest_seq, DMScopeChanged), @@ -97,7 +106,10 @@ def test_painted_bound_uses_existing_transcript_refresh_error(tmp_path: Path): comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with pytest.raises(TranscriptChanged): page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", @@ -111,7 +123,10 @@ def test_root_replaced_after_fetch_rejects_mark_without_reading_new_root(tmp_pat comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted in original root").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) previous = tmp_path.with_name(tmp_path.name + "-previous") tmp_path.rename(previous) shutil.copytree(previous, tmp_path, symlinks=True) @@ -125,7 +140,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) comms.registry.declare(_thread(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_user_view_read("peer", worktree=Checkout(tmp_path)) comms.messaging.send("peer", viewer, "unpainted after explicit mark").message.message_id comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -135,7 +153,10 @@ def test_independent_read_is_idempotent_and_never_retargets_page(tmp_path: Path) def test_empty_dm_basis_is_no_op_without_creating_read_ledger(tmp_path: Path): comms = installed_comms(tmp_path) comms.registry.declare(_thread(tmp_path, "peer")) - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.displayed.tail == EmptyTail() page.selection.basis.acknowledge( comms.bus, comms.registry, comms.root, "peer", diff --git a/tests/test_dm_scoped_ack_successor.py b/tests/test_dm_scoped_ack_successor.py index e1a84b31f4..4f7117f3ad 100644 --- a/tests/test_dm_scoped_ack_successor.py +++ b/tests/test_dm_scoped_ack_successor.py @@ -42,7 +42,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "before rename").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.threads.rename_managed_thread("peer", "renamed", owner=ProcessIdentity.capture(os.getpid())) markers = tmp_path / "read_ledger.json" marker_before = markers.read_bytes() if markers.exists() else None @@ -53,7 +56,10 @@ def test_alias_rename_invalidates_old_basis_but_fresh_alias_page_can_mark(tmp_pa # The old spelling is now an alias for the SAME incarnation. A fresh # page, not stale name text, is authoritative after the rename. - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) and fresh.selection.basis.peer == "renamed" _mark(comms, "peer", tmp_path, fresh) assert comms.bus.pending_count(viewer, CommandTarget.named("renamed")) == 0 @@ -66,7 +72,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old peer painted").message.message_id - old = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + old = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(old.selection, DMSelection) comms.registry.unregister("peer") comms.registry.remove("peer") @@ -80,7 +89,10 @@ def test_delete_and_same_name_reregister_invalidates_old_peer_basis(tmp_path: Pa _mark(comms, "peer", tmp_path, old) assert (markers.read_bytes() if markers.exists() else None) == marker_before assert comms.bus.pending_count(viewer, CommandTarget.named("peer")) == 1 - fresh = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(fresh.selection, DMSelection) assert fresh.selection.basis.peer_identity != old.selection.basis.peer_identity @@ -90,7 +102,10 @@ def test_viewer_rebind_and_foreign_worktree_reject_old_basis(tmp_path: Path): comms.registry.declare(_peer(tmp_path, "peer")) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path / "foreign")) @@ -110,7 +125,10 @@ def test_old_viewer_alive_but_new_human_selected_rejects_stale_basis(tmp_path: P comms.registry.declare(_peer(tmp_path, "peer")) old_viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", old_viewer, "old viewer painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert isinstance(page.selection, DMSelection) and page.selection.basis.viewer == old_viewer comms.registry.declare(Thread('new_user', frozenset(), Checkout(tmp_path), role=UserThreadRole)) # Normal registry rename retains the old human declaration but moves its @@ -135,7 +153,10 @@ def test_newest_page_mark_leaves_older_message_unread(tmp_path: Path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name older = comms.messaging.send("peer", viewer, "older").message newest = comms.messaging.send("peer", viewer, "newest").message - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=1)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=1)) assert page.messages == (newest,) _mark(comms, "peer", tmp_path, page) assert comms.bus.inbox(viewer, CommandTarget.named("peer")).page.messages == (older,) @@ -147,7 +168,10 @@ def test_lifecycle_and_marker_lock_order_does_not_deadlock(tmp_path: Path): comms.registry.declare(_peer(tmp_path, name)) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("peer", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("peer"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) with ThreadPoolExecutor(max_workers=2) as pool: marked = pool.submit(_mark, comms, "peer", tmp_path, page) lifecycle = pool.submit( diff --git a/tests/test_historical_page_scope.py b/tests/test_historical_page_scope.py index 62c16f8d3b..5198ffd1bc 100644 --- a/tests/test_historical_page_scope.py +++ b/tests/test_historical_page_scope.py @@ -86,7 +86,9 @@ def reader(live, kind): return lambda bounds: live.views.history_page(CommandTarget.named("carol"), bounds, viewer=NamedViewer(name)) if kind == "any-mode": live.channels.set_channel_any_mode("#api", True) - return lambda bounds: live.views.channel_display_page("#api", bounds=bounds) + return lambda bounds: live.views.channel_display_view( + '#api', + ).page(live.views.presentation, bounds=bounds) target = { "channel": "#api", "any": "#any", diff --git a/tests/test_historical_views.py b/tests/test_historical_views.py index 84f2c3bc21..74f13d4fa2 100644 --- a/tests/test_historical_views.py +++ b/tests/test_historical_views.py @@ -4,7 +4,9 @@ from agent_comms.repo import Checkout import json import os +from collections.abc import Callable from dataclasses import replace +from functools import partial from pathlib import Path import pytest @@ -19,7 +21,7 @@ from agent_comms.turn_item import FreshNativeId from agent_comms.historical_views import HistoricalMessage, HistoryArchive, HistorySource from agent_comms.installer.backup import bus_files -from agent_comms.message_page import MessagePageRequest, PageBounds +from agent_comms.message_page import MessagePage, MessagePageRequest, PageBounds from agent_comms.messages import Message from agent_comms.page_traversal import HistoryCursor from agent_comms.read_basis import ( @@ -57,6 +59,43 @@ def test_missing_catalogue_is_empty_without_creating_it(tmp_path): assert not archive.path.exists() +@pytest.mark.parametrize('exists', [False, True]) +def test_captured_catalogue_refuses_later_creation_or_replacement(tmp_path, exists): + archive = HistoryArchive.at(tmp_path) + if exists: + archive.path.write_text('[]') + captured = archive.captured() + with captured.read_sources() as sources: + assert sources == () + archive.path.write_text('[ ]') + with pytest.raises(HistoricalSourceChanged): + with captured.read_sources(): + pass + with archive.read_sources() as sources: + assert sources == () + + +def test_held_display_reading_rejects_later_archive_without_bus_growth(migrated, tmp_path): + _, _, live, _ = migrated + with live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).reading(live.views.presentation) as read: + first = read(PageBounds(limit=1)) + revision = live.bus.bus.revision() + later = setup(tmp_path / 'later', 1) + live.views.attach_history(later.root) + assert live.bus.bus.revision() == revision + with pytest.raises(HistoricalSourceChanged): + read(PageBounds(first.oldest_cursor.older(), 1)) + # Retiring the old reading is harmless; a new reading adopts the catalogue. + fresh = live.views.channel_display_view( + '#team', + viewer=NamedViewer('alice'), + ).page(live.views.presentation, bounds=PageBounds(limit=1)) + assert fresh.selection.history_revision != first.selection.history_revision + + def setup(root, count=5): comms = wire(root) comms.registry.declare(Thread('alice', frozenset({'team'}), Checkout(root), created_at=10.0)) @@ -68,13 +107,14 @@ def setup(root, count=5): return comms -def collect(comms, method, *args, **kwargs): - page = getattr(comms.views, method)(*args, bounds=PageBounds(limit=3), **kwargs) +def collect(read: Callable[[PageBounds], MessagePage]): + """Every message one page reader yields, paging backward three at a time.""" + page = read(PageBounds(limit=3)) result = list(page.messages) for _ in range(40): if not page.has_older: return result - page = getattr(comms.views, method)(*args, bounds=PageBounds(page.oldest_cursor.older(), 3), **kwargs) + page = read(PageBounds(page.oldest_cursor.older(), 3)) result[0:0] = page.messages raise AssertionError("paging did not terminate") @@ -92,18 +132,20 @@ def migrated(tmp_path): @pytest.mark.parametrize( - "method,args,reader", + "view_factory,expected", [ - ("channel_display_page", ("#team",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), - ("dm_display_page", (ThreadTarget("alice"),), lambda live: dict(worktree=Checkout(live.root))), - ("channel_display_page", ("#any",), lambda live: dict(viewer=live.views.display_viewer(Checkout(live.root)))), + (lambda live: live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root))), 9), + (lambda live: live.views.dm_display_view( + ThreadTarget("alice"), worktree=Checkout(live.root)), 9), + (lambda live: live.views.channel_display_view( + "#any", viewer=live.views.display_viewer(Checkout(live.root))), 18), ], ) -def test_all_history_backward_and_forward(migrated, method, args, reader): +def test_all_history_backward_and_forward(migrated, view_factory, expected): old, prior, live, source = migrated - scope = reader(live) - rows = collect(live, method, *args, **scope) - expected = 18 if args == ("#any",) else 9 + view = view_factory(live) + rows = collect(partial(view.page, live.views.presentation)) assert len(rows) == expected assert len({row.view_key for row in rows}) == expected assert len({row.seq for row in rows}) < expected @@ -111,7 +153,7 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): forward = [] cursor = HistoryCursor(source=source.key, sequence=0) for _ in range(40): - page = getattr(live.views, method)(*args, bounds=PageBounds(cursor.newer(), 2), **scope) + page = view.page(live.views.presentation, PageBounds(cursor.newer(), 2)) forward.extend(page.messages) if not page.has_newer: break @@ -121,7 +163,9 @@ def test_all_history_backward_and_forward(migrated, method, args, reader): def test_original_identity_and_no_delivery_authority(migrated): old, prior, live, source = migrated - rows = collect(live, "channel_display_page", "#team", viewer=live.views.display_viewer(Checkout(live.root))) + rows = collect(partial(live.views.channel_display_view( + "#team", viewer=live.views.display_viewer(Checkout(live.root)), + ).page, live.views.presentation)) historical = [m for m in rows if isinstance(m, HistoricalMessage) and m.source == source] original = list(old.views.history_page(CommandTarget.named("#team")).messages) assert [m.to_wire() for m in historical] == [Message.from_message(m).to_wire() for m in original] @@ -135,8 +179,11 @@ def test_original_identity_and_no_delivery_authority(migrated): def test_sparse_historical_ack_does_not_ack_live(migrated): _, _, live, source = migrated viewer = live.messaging.user_identity(Checkout(live.root)) - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page( + live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 3), ) live.views.mark_page_read(page.select([page.messages[1].seq]), worktree=Checkout(live.root)) @@ -163,11 +210,15 @@ def test_select_painted_page_keeps_its_source_with_attached_history(migrated, co _, _, live, source = migrated bounds = PageBounds(HistoryCursor(source=source.key, sequence=100).older(), 2) if archived else PageBounds(limit=2) if conversation == "channel": - page = live.views.channel_display_page( - "#team", viewer=live.views.display_viewer(Checkout(live.root)), bounds=bounds, - ) + page = live.views.channel_display_view( + '#team', + viewer=live.views.display_viewer(Checkout(live.root)), + ).page(live.views.presentation, bounds=bounds) else: - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root), bounds=bounds) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation, bounds=bounds) message = page.messages[-1] selected = page.select((message.seq,)) assert selected.messages == (message,) @@ -239,7 +290,12 @@ def test_duplicate_and_newer_incarnations_sessions(tmp_path): def test_stale_cursor_is_rejected(migrated): _, _, live, _ = migrated with pytest.raises(HistoricalSourceChanged): - live.views.channel_display_page("#team", bounds=PageBounds(HistoryCursor(source="detached", sequence=2).older())) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source='detached', sequence=2).older()), + ) def test_recorded_transcript_witness_roundtrip_keeps_original_namespace(tmp_path): @@ -302,10 +358,10 @@ def test_detached_recorded_witness_cannot_rebind_to_same_name_live_thread(migrat def test_normal_comms_api_contains_history_and_execution_stays_live(migrated): _, _, live, _ = migrated viewer = NamedViewer(live.messaging.user_identity(Checkout(live.root)).name) - assert len(collect(live, "history_page", CommandTarget.named("#team"))) == 9 - assert len(collect(live, "history_page", CommandTarget.named("alice"), viewer=viewer)) == 9 - assert len(collect(live, "history_page", CommandTarget.named("#any"))) == 18 - for message in collect(live, "history_page", CommandTarget.named("#any")): + assert len(collect(partial(live.views.history_page, CommandTarget.named("#team")))) == 9 + assert len(collect(partial(live.views.history_page, CommandTarget.named("alice"), viewer=viewer))) == 9 + assert len(collect(partial(live.views.history_page, CommandTarget.named("#any")))) == 18 + for message in collect(partial(live.views.history_page, CommandTarget.named("#any"))): if isinstance(message, HistoricalMessage): assert not message.role.response_policy(message).starts_turn assert not message.role.response_policy(message).starts_turn_for(message, "bob") @@ -328,10 +384,10 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) source = live.views.attach_history(old.root) assert root_bytes(old.root) == original assert not (Path(source.root) / "unknown-input.json").exists() - page = live.views.channel_display_page( - "#team", viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), - bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), - ) + page = live.views.channel_display_view( + '#team', + viewer=NamedViewer(live.messaging.user_identity(Checkout(live.root)).name), + ).page(live.views.presentation, bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older())) archived = Bus(Bus.path_in(Path(source.root))) original = page.messages[0] archived.append(lambda seq, db: PublicRecord.from_message(Message( @@ -339,8 +395,11 @@ def test_attach_only_reads_original_roots_and_rejects_changed_snapshot(tmp_path) with pytest.raises(ValueError): live.views.mark_page_read(page, worktree=Checkout(live.root)) with pytest.raises(ValueError): - live.views.channel_display_page( - "#team", bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()) + live.views.channel_display_view( + '#team', + ).page( + live.views.presentation, + bounds=PageBounds(HistoryCursor(source=source.key, sequence=100).older()), ) @@ -359,7 +418,10 @@ def test_missing_historical_peer_is_browsable_without_live_registration(migrated _, _, live, source = migrated live.registry.unregister("alice") live.registry.remove("alice") - page = live.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(live.root)) + page = live.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(live.root), + ).page(live.views.presentation) assert page.messages assert all(isinstance(message, HistoricalMessage) for message in page.messages) assert "alice" not in live.registry diff --git a/tests/test_mark_all_existing_reads.py b/tests/test_mark_all_existing_reads.py index 12248cb4cb..12b8292971 100644 --- a/tests/test_mark_all_existing_reads.py +++ b/tests/test_mark_all_existing_reads.py @@ -11,7 +11,10 @@ def test_mark_all_after_partial_reads_preserves_next_unread_message(tmp_path): viewer = comms.messaging.user_identity(Checkout(tmp_path)).name first = comms.messaging.send('alice', '#team', 'already painted').message comms.messaging.send('bob', '#team', 'not painted').message - page = comms.views.channel_display_page('#team', viewer=NamedViewer(viewer)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(comms.views.presentation) selection = page.selection scope = selection.scope page = replace(page, selection=replace(selection, scope=replace( diff --git a/tests/test_mutual_relation_identity.py b/tests/test_mutual_relation_identity.py index b367ef03a4..1da49cf084 100644 --- a/tests/test_mutual_relation_identity.py +++ b/tests/test_mutual_relation_identity.py @@ -29,8 +29,8 @@ def peer_incarnation(self): relation = ReviewRelation() assert relation.incident(first) and relation.incident(second) - assert relation.counterpart(first) == second.incarnation - assert relation.counterpart(second) == first.incarnation + assert tuple(relation.counterparts(first)) == (second.incarnation,) + assert tuple(relation.counterparts(second)) == (first.incarnation,) assert not relation.incident(replacement) - assert relation.counterpart(replacement) is None + assert tuple(relation.counterparts(replacement)) == () assert relation.record(second.incarnation).peer == "second" diff --git a/tests/test_named_target_contracts.py b/tests/test_named_target_contracts.py index 58ad93e6cd..7ec10aed53 100644 --- a/tests/test_named_target_contracts.py +++ b/tests/test_named_target_contracts.py @@ -9,8 +9,10 @@ from agent_comms.coordination_errors import DMScopeChanged from agent_comms.historical_views import ConversationObservation, DMDisplayHistory from agent_comms.message_page import ConversationMessagePageRequest +from agent_comms.messages import Message from agent_comms.read_basis import NamedViewer from agent_comms.thread_execution import ExternalThreadExecution +from agent_comms.thread_identity import BirthlessIncarnation from bus_record_fixture import context_comms from agent_comms.threads import Thread @@ -64,15 +66,24 @@ def test_retained_dm_target_follows_rename_through_send_page_and_revision(tmp_pa comms = context_comms(tmp_path) worktree = Checkout(tmp_path) target = IncarnationThreadTarget(comms.registry.require("Alice").incarnation) + original_cut = comms.registry.snapshot() + assert target.incarnation.require_current(original_cut).name == "Alice" original = comms.messaging.send_user_message(target, "before rename", worktree=worktree) comms.registry.rename("Alice", "renamed") + renamed_cut = comms.registry.snapshot() + current = target.incarnation.require_current(renamed_cut) + assert current == target.incarnation + assert current.name == "renamed" + assert target.selected_name(renamed_cut) == current.name + assert target.incarnation.resolved(renamed_cut).name == current.name + assert target.incarnation.require_current(original_cut).name == "Alice" sent = comms.messaging.send_user_message(target, "after rename", worktree=worktree) assert sent.target == "renamed" delivery = comms.bus.bus.delivery(sent.seq) assert [(row.canonical_thread, row.recipient_lookup) for row in delivery.recipients] == [ ("renamed", target.incarnation.require_recorded().lookup)] - page = comms.views.dm_display_page(target, worktree=worktree) - assert page.messages == (original, sent) + page = comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) + assert page.messages == (Message.from_message(original), Message.from_message(sent)) assert page.selection.basis.peer_identity == target.incarnation generic_page = comms.views.history_page(target, viewer=NamedViewer(sent.sender)) assert generic_page.messages == page.messages @@ -92,18 +103,36 @@ def test_retained_dm_refuses_a_removed_birth_at_every_selection_boundary(tmp_pat comms.registry.remove("Alice") if recreate: comms.registry.declare(Thread("Alice", frozenset({"team"}), worktree)) + snapshot = comms.registry.snapshot() + assert not target.incarnation.current(snapshot) + assert target.incarnation.resolved(snapshot) is target.incarnation + with pytest.raises(UnregisteredThreadError): + target.incarnation.require_current(snapshot) + with pytest.raises(UnregisteredThreadError): + target.selected_name(snapshot) before = comms.bus.bus.revision() with pytest.raises(HumanSendRefusedError): comms.messaging.send_user_message(target, "must not reach successor", worktree=worktree) assert comms.bus.bus.revision() == before with pytest.raises(UnregisteredThreadError): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) with pytest.raises(UnregisteredThreadError): DMDisplayHistory.revision(comms.views, target, ConversationObservation(), worktree=worktree) with pytest.raises(UnregisteredThreadError): comms.views.history_page(target, viewer=NamedViewer(viewer.name)) +@pytest.mark.parametrize("member", BirthlessIncarnation.members_with(BirthlessIncarnation)) +def test_birthless_identity_cannot_strictly_select_a_current_peer(named_target_journey, member): + incarnation = member.recorded_as("Alice") + snapshot = named_target_journey.registry.snapshot() + assert incarnation.resolved(snapshot) is incarnation + with pytest.raises(ValueError): + incarnation.require_current(snapshot) + with pytest.raises(ValueError): + IncarnationThreadTarget(incarnation) + + def test_dm_page_refuses_replacement_between_registry_capture_and_bus_read(tmp_path, monkeypatch): comms = context_comms(tmp_path) worktree = Checkout(tmp_path) @@ -121,6 +150,6 @@ def replace_before_read(request, source): monkeypatch.setattr(ConversationMessagePageRequest, "read_opened", replace_before_read) with pytest.raises(DMScopeChanged): - comms.views.dm_display_page(target, worktree=worktree) + comms.views.dm_display_view(target, worktree=worktree).page(comms.views.presentation) assert len(successor_messages) == 1 assert comms.bus.bus.lookup(successor_messages[0].seq) == successor_messages[0] diff --git a/tests/test_ordinary_nk_delivery.py b/tests/test_ordinary_nk_delivery.py index 7f77aef6e5..2194fc9c28 100644 --- a/tests/test_ordinary_nk_delivery.py +++ b/tests/test_ordinary_nk_delivery.py @@ -33,4 +33,7 @@ def test_fresh_send_uses_canonical_publication_and_human_delivery_has_no_wake(tm assert isinstance(human.message.role, NoticeRole) assert human.recipients == () assert human.decisions == () - assert comms.views.dm_display_page(ThreadTarget("sender"), worktree=Checkout(tmp_path)).messages[-1] == reply + assert comms.views.dm_display_view( + ThreadTarget('sender'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation).messages[-1] == reply diff --git a/tests/test_owner_release_store.py b/tests/test_owner_release_store.py index 3f0ebada86..46577bf310 100644 --- a/tests/test_owner_release_store.py +++ b/tests/test_owner_release_store.py @@ -13,20 +13,25 @@ def test_release_survives_metadata_changes_and_reads_only_its_row(tmp_path): comms = wire(tmp_path) originals = [comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) for name in ('worker', 'other')] - generation = comms.registry.snapshot().claim_generation('worker') + before = comms.registry.snapshot() + selected = tuple((thread, before.claim_generation(thread.name)) for thread in originals) + generation = before.claim_generation('worker') + # This store contract needs a stopped cut, not physical process retirement. + # NoProcess stop correctly produces no release; record the explicit fixture here. for thread in originals: - comms.owners.stop(thread.name) + comms.registry.unregister(thread.name) snapshot = comms.registry.snapshot() + releases = comms.owners.releases + releases.record(snapshot, selected) current = snapshot.threads['worker'] comms.registry.register(replace(current, title=GivenThreadText('changed title'), task=GivenThreadText('changed task')), status=snapshot.statuses['worker']) - releases = comms.owners.releases # An invalid unrelated row must not even be decoded by this lookup. with releases.writing() as db: OwnerReleaseEvidence.update(db, where='name=?', parameters=('other',), after=0) record = releases.read_many(('worker',))['worker'] assert record.current(comms.registry.snapshot(), originals[0], generation) - assert record.incarnation() == originals[0].incarnation + assert record.incarnation == originals[0].incarnation assert record.process_identity == originals[0].process_identity releases.remove_threads((originals[0],)) assert isinstance(releases.read_many(('worker',))['worker'], NoRelease) diff --git a/tests/test_read_ledger.py b/tests/test_read_ledger.py index 2962856e2f..5f082fe8aa 100644 --- a/tests/test_read_ledger.py +++ b/tests/test_read_ledger.py @@ -150,7 +150,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): comms = prepared(tmp_path) for i in range(5): comms.messaging.send("alice", "#team", f"message {i}").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=2)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=2)) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 3 @@ -158,7 +161,10 @@ def test_bounded_page_marks_only_shown_messages(tmp_path): def test_displayed_message_is_read_in_every_projection(tmp_path): comms = prepared(tmp_path) comms.messaging.send("alice", "#team", "visible in both views").message.message_id - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert wire(tmp_path).views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#any"].count == 0 @@ -169,7 +175,10 @@ def test_any_mode_expansion_cannot_read_unshown_message_in_same_conversation(tmp hidden = comms.messaging.send("bob", "carol", "no member mentioned").message shown = comms.messaging.send("bob", "carol", "@alice shown by mention").message comms.channels.set_channel_any_mode("#team", True) - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in page.messages] == [shown.seq] comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) comms.channels.update_tags("bob", add=frozenset({"team"})) @@ -189,10 +198,16 @@ def test_turn_lease_and_unrelated_registry_changes_do_not_invalidate_dm(tmp_path comms.registry.register(replace(peer, process_identity=ProcessIdentity.capture(os.getpid()))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", viewer, "painted").message.message_id - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert page.selection.basis.peer_identity == peer.incarnation comms.registry.lease_local_turn("alice", TurnId('0b225c32b06b9107d59b5d92d13b83a3')) - fresh = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert fresh.selection.basis.peer_identity == page.selection.basis.peer_identity comms.registry.register(Thread('unrelated', frozenset(), Checkout(tmp_path))) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) @@ -213,9 +228,11 @@ def test_dm_ack_survives_process_exit_and_rebind_does_not_inherit_reads(tmp_path "import os, sys\n" "from agent_comms.channel_targets import ThreadTarget\n" "from agent_comms.comms import wire\n" + "from agent_comms.repo import Checkout\n" "comms = wire(sys.argv[1])\n" - "page = comms.views.dm_display_page(ThreadTarget('alice'), worktree=sys.argv[1])\n" - "comms.views.mark_page_read(page, worktree=sys.argv[1])\n" + "project = Checkout(sys.argv[1])\n" + "page = comms.views.dm_display_view(ThreadTarget('alice'), worktree=project).page(comms.views.presentation)\n" + "comms.views.mark_page_read(page, worktree=project)\n" "os._exit(9)\n", str(tmp_path), ], @@ -243,12 +260,12 @@ def test_shown_only_property_across_pages_modes_and_reopen(tmp_path, seed): for i in range(12)] for _ in range(18): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) - page = comms.views.channel_display_page( - rng.choice(["#team", "#any"]), + page = comms.views.channel_display_view( + rng.choice(['#team', '#any']), viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), - bounds=PageBounds( - limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older() - ), + ).page( + comms.views.presentation, + bounds=PageBounds(limit=rng.randrange(1, 8), traversal=FieldCodec.decode(PageCursor, rng.choice([None, 6, 10])).older()), ) if page.messages: through = rng.choice(page.messages).seq @@ -324,7 +341,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): comms = prepared(tmp_path) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name comms.messaging.send("alice", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) replacement = tmp_path / "replacement.sqlite3" comms.bus.bus.backup(replacement) @@ -335,7 +355,10 @@ def test_replaced_bus_keeps_no_sequence_read_facts(tmp_path): from agent_comms.coordination_errors import DMScopeChanged with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.channel_display_page("#team", viewer=NamedViewer(viewer)) + fresh = reopened.views.channel_display_view( + '#team', + viewer=NamedViewer(viewer), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).channel_unread["#team"].count == 0 @@ -354,7 +377,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert not ledger.read().bus_identity.matches(ObservedPath.at(comms.bus.bus.path)) assert comms.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) recorded = RecordedBus(FileIdentity.from_stat(comms.bus.bus.path.stat())) assert ledger.read().bus_identity == recorded == page.selection.basis.bus_identity @@ -375,7 +401,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 1 with pytest.raises(DMScopeChanged): reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) - fresh = reopened.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path)) + fresh = reopened.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(fresh, worktree=Checkout(tmp_path)) assert reopened.views.viewer_snapshot(Checkout(tmp_path)).unread["alice"].count == 0 @@ -383,7 +412,10 @@ def test_recorded_bus_identity_is_one_member(tmp_path): def test_partial_paints_share_one_basis_across_read_progress(tmp_path): comms = prepared(tmp_path) messages = [comms.messaging.send("alice", "#team", f"row {i}").message for i in range(5)] - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) scope = page.selection.scope assert scope is not None and scope.displayed is not None # Requested sequences outside the original page cannot become read facts. @@ -448,13 +480,19 @@ def current_cursor(facts): comms.channels.set_channel_any_mode("#team", rng.choice([True, False])) elif operation == "display": if rng.choice([True, False]): - page = comms.views.dm_display_page(ThreadTarget("alice"), worktree=Checkout(tmp_path), bounds=PageBounds(limit=3)) + page = comms.views.dm_display_view( + ThreadTarget('alice'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.basis.displayed kind, target = "dm", "alice" proof = page.selection.basis else: target = rng.choice(["#team", "#any"]) - page = comms.views.channel_display_page(target, viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), bounds=PageBounds(limit=3)) + page = comms.views.channel_display_view( + target, + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation, bounds=PageBounds(limit=3)) basis = page.selection.scope.displayed kind, proof = "channel", page.selection.scope selected = {m.seq for m in page.messages if rng.choice([True, False])} diff --git a/tests/test_recipient_activity_index.py b/tests/test_recipient_activity_index.py index a335853017..9d319f49d1 100644 --- a/tests/test_recipient_activity_index.py +++ b/tests/test_recipient_activity_index.py @@ -11,13 +11,16 @@ from agent_comms.activity import Activity, ActivityState, ActivityStore from agent_comms.audience_manifest import FrozenRecipient from agent_comms.comms import wire -from agent_comms.errors import RelationViolationError +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import ThreadLookup from agent_comms.notification_assignment import NotificationAssignment from agent_comms.presentation import AllOutcomes, MessageNotification, OwnerOutcomes from agent_comms.messages import InfoKind, Message from agent_comms.read_ledger import ReadDocument, ReadLedger from agent_comms.read_basis import ConversationCursor, ReadCursor -from agent_comms.registry_provenance import RegistryProvenance +from agent_comms.registry_provenance import ( + RegisteredRecipient, RegistryProvenance, RetiredRecipient, +) from agent_comms.registry_document import RegistrySnapshot from agent_comms.thread_identity import ( UnrecordedThreadIncarnation, @@ -65,6 +68,39 @@ def test_archived_duplicate_birth_keeps_names_without_selecting_an_arbitrary_own assert not hasattr(unrecorded.threads["second"].incarnation, "lookup") +def test_captured_lookup_keeps_original_recipient_identity_and_retirement(tmp_path): + comms = canonical_goal_wire(tmp_path / "bus") + for name in ("sender", "peer", "other"): + comms.registry.declare(Thread(name, frozenset(), Checkout(tmp_path))) + sent = comms.messaging.send("sender", "peer", "retained recipient").message + original = comms.bus.bus.delivery(sent.seq) + captured = comms.registry.snapshot() + peer = captured.require("peer") + recipient = FrozenRecipient(peer.incarnation.lookup, peer.name) + assert recipient in original.recipients + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + with pytest.raises(RelationViolationError): + captured.recipient(FrozenRecipient(recipient.recipient_lookup, "other"), original) + foreign = FrozenRecipient(ThreadLookup.mint(), peer.name) + with pytest.raises(UnregisteredThreadError): + captured.recipient(foreign, original) + + comms.registry.rename(peer.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.recipient(recipient, original) == RegisteredRecipient(renamed.require("renamed")) + comms.registry.unregister("renamed") + comms.threads.delete("renamed") + deleted = comms.registry.snapshot() + assert deleted.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + with pytest.raises(UnregisteredThreadError): + deleted.recipient(foreign, original) + recreated = comms.registry.declare(Thread("renamed", frozenset(), Checkout(tmp_path))) + current = comms.registry.snapshot() + assert recreated.incarnation.lookup != recipient.recipient_lookup + assert current.recipient(recipient, original) == RetiredRecipient(recipient.recipient_lookup) + assert captured.recipient(recipient, original) == RegisteredRecipient(peer) + + def test_activity_store_selects_only_requested_threads(tmp_path): store = ActivityStore(tmp_path) for index in range(10): diff --git a/tests/test_registration_components.py b/tests/test_registration_components.py index 507d11b937..2cf6405e84 100644 --- a/tests/test_registration_components.py +++ b/tests/test_registration_components.py @@ -3,6 +3,7 @@ from agent_comms.repo import Checkout import json import os +import pickle import subprocess import sys from dataclasses import dataclass, field, replace @@ -157,17 +158,92 @@ def test_lifecycle_document_survives_fresh_process_restart(tmp_path): assert restored.snapshot().owner_generations["renamed"] > 0 -def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(tmp_path): - from agent_comms.registry_document import ThreadRows +def test_thread_rows_compare_unread_rows_as_stored_and_decode_only_what_changed(comms, tmp_path): + from agent_comms.field_codec import FieldCodec + from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows from agent_comms.thread_execution import ExternalThreadExecution - # A row that cannot decode proves an unread, unchanged row is never decoded to compare. - stored = {'unread': {'name': 'unread', 'not': 'a thread'}} + # A real declaration with malformed non-determining data stays lazy until a full read. + thread = comms.registry.declare(Thread('unread', frozenset(), Checkout(tmp_path))) + row = FieldCodec.encode_fields(thread) + row[FieldCodec.field_plan(Thread, 'model').key] = 123 + stored = {'unread': row} + with pytest.raises(InvalidRegistryDocument): + ThreadRows.from_wire(stored)['unread'] assert ThreadRows.from_wire(stored) == ThreadRows.from_wire(stored) written = ThreadRows.from_wire(stored) - written['unread'] = Thread( - 'unread', frozenset(), Checkout(tmp_path), execution=ExternalThreadExecution) + written['unread'] = replace(thread, execution=ExternalThreadExecution) other = ThreadRows.from_wire({}) other['unread'] = written['unread'] assert written == other assert written != ThreadRows.from_wire({}) + + +def test_birth_projection_agrees_for_full_thread_and_stored_float_records(tmp_path): + from agent_comms.thread_identity import ThreadIncarnationRecord + + thread = Thread("owner", frozenset(), Checkout(tmp_path)) + stored = ThreadIncarnationRecord(thread.name, thread.created_at.to_wire()) + assert thread.birth == stored.birth + assert thread.incarnation == stored.incarnation + assert FieldCodec.decode(type(stored), FieldCodec.encode(stored)) == stored + + +def test_full_thread_read_keeps_original_raw_wire_until_an_actual_assignment(comms, tmp_path): + from agent_comms.registry_document import ThreadRows + + thread = comms.registry.declare(Thread("owner", frozenset({"kept"}), Checkout(tmp_path))) + rows = ThreadRows.from_wire({thread.name: FieldCodec.encode_fields(thread)}) + tags_key = FieldCodec.field_plan(Thread, "tags").key + kept_tags = rows.to_wire()[thread.name][tags_key] + loaded = rows[thread.name] + assert loaded == thread + assert rows.to_wire()[thread.name][tags_key] is kept_tags + rows[thread.name] = replace(loaded, tags=frozenset({"assigned"})) + assert rows.to_wire()[thread.name][tags_key] == ["assigned"] + + +def test_captured_thread_row_uses_its_wire_decode_and_pickle_protocol(comms, tmp_path): + from agent_comms.registry_document import ThreadRow + + thread = comms.registry.declare(Thread("captured", frozenset({"kept"}), Checkout(tmp_path))) + row = ThreadRow.from_wire(FieldCodec.encode_fields(thread)) + decoded = FieldCodec.decode(ThreadRow, FieldCodec.encode(row)) + restored = pickle.loads(pickle.dumps(row, protocol=5)) + for captured in (decoded, restored): + assert type(captured) is ThreadRow + assert captured == row + assert captured.to_wire() == row.to_wire() + assert captured.birth_record.incarnation == thread.incarnation + assert captured.decoded == thread + + +def test_thread_row_copy_reuse_and_pickle_keep_the_bound_cut(comms, tmp_path): + from agent_comms.registry_document import FrozenThreadRows, ThreadRows + from agent_comms.session_file import BusSessionLocation + + first = comms.registry.declare(Thread("first-row", frozenset({"old"}), Checkout(tmp_path))) + second = comms.registry.declare(Thread("second-row", frozenset(), Checkout(tmp_path))) + first = replace(first, session_file=BusSessionLocation(Path("row-session.jsonl"))) + rows = ThreadRows.from_threads({first.name: first, second.name: second}) + rows.bind(comms.registry.store._bind_thread) + before = FrozenThreadRows.copied(rows) + acquired = before[first.name] + assert before[first.name] is acquired + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + replacement = replace(acquired, tags=frozenset({"new"})) + rows[first.name] = replacement + assert rows[first.name] is replacement + assert before[first.name] is acquired + assert before.membership(first.name).tags == frozenset({"old"}) + assert rows.membership(first.name).tags == frozenset({"new"}) + assert rows._rows[second.name] is before._rows[second.name] + reused = ThreadRows() + reused.reuse(rows.to_wire(), frozenset({second.name}), before) + assert reused._rows[second.name] is before._rows[second.name] + assert reused[first.name] == replacement + for restored in (pickle.loads(pickle.dumps(before, protocol=5)), + pickle.loads(pickle.dumps(rows, protocol=5))): + selected = restored[first.name] + assert selected.session_file.bound_to(comms.root) is selected.session_file + assert restored[first.name] is selected diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 05b967883b..a0470ccc68 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -4,17 +4,25 @@ import json from concurrent.futures import ProcessPoolExecutor from dataclasses import replace +from pathlib import Path +from types import MappingProxyType import pytest -from agent_comms.registry_document import RosterVisibility +from agent_comms.registry_document import InvalidRegistryDocument, RosterVisibility, ThreadRows from agent_comms.relationships import ( ChildrenRelationshipGroup, CollaboratingRelationshipGroup, InboundRelationshipGroup, - OutboundRelationshipGroup, ParentRelationshipGroup, RelationshipEdit, + NamedEntrySource, OutboundRelationshipGroup, ParentRelationshipGroup, + RelationshipContext, RelationshipEdit, RelationshipEntry, RelationshipEntrySource, + RelationshipGroup, RelationshipInputs, SortableRelationshipGroup, ) -from agent_comms.comms import wire +from agent_comms.comms import Comms, wire from conftest import installed_comms -from agent_comms.display_order import LastActivityThreadSort +from agent_comms.display_order import LastActivityThreadSort, ThreadSort +from agent_comms.field_codec import FieldCodec +from agent_comms.bus import PublicRecord +from agent_comms.messages import InfoKind, Message, NoticeRole +from agent_comms.routing import DeliveryScope from agent_comms.thread_text import ForkedParent, NoParent from agent_comms.tools import invoke_tool from agent_comms.threads import Thread @@ -37,7 +45,7 @@ def change_shared_peer(root, instruction): wire(root).relationships.edit(owner, RelationshipEdit.member_named(action), other, "Race") -def setup_wire(tmp_path): +def setup_wire(tmp_path: Path) -> Comms: comms = installed_comms(tmp_path / "wire") for name, parent in (("origin", NoParent()), ("owner", ForkedParent("origin")), ("peer", NoParent()), ("child", ForkedParent("owner"))): @@ -120,6 +128,71 @@ def test_alias_and_persisted_thread_sort(tmp_path): assert collaboration_rows(comms, "reviewer")[0].target == "renamed" +def test_recent_relationships_keep_the_global_cut_and_exact_preview(tmp_path): + comms = setup_wire(tmp_path) + bus = comms.bus.bus + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "evicted relevant input", InfoKind, seq=seq), + )) + for _ in range(comms.relationships.RECENT_MESSAGES): + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "origin", "unrelated global row", InfoKind, seq=seq), + )) + snapshot = comms.relationships.snapshot("owner") + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + assert not snapshot.groups_by_type[InboundRelationshipGroup].entries + + body = "preview " * 50 + latest = bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", body, InfoKind, seq=seq), + )) + bus.append(lambda seq, db: PublicRecord.from_message( + Message("peer", "owner", "not a conversation", InfoKind, seq=seq, role=NoticeRole()), + )) + snapshot = comms.relationships.snapshot("owner") + entry = snapshot.groups_by_type[InboundRelationshipGroup].entries["peer"] + assert entry.sequence == latest.seq and entry.timestamp == latest.timestamp + assert entry.detail == f"peer → owner\n{body[:240]}" + assert snapshot.history_limited and snapshot.history_messages == comms.relationships.RECENT_MESSAGES + + +@pytest.mark.parametrize("order", ThreadSort.members_with(ThreadSort)) +def test_group_selection_hydrates_only_selected_threads_on_its_captured_cut( + tmp_path: Path, order: type[ThreadSort], +): + comms = setup_wire(tmp_path) + comms.registry.declare(Thread("unselected", frozenset(), Checkout(tmp_path))) + comms.relationships.edit("owner", RelationshipEdit.member_named("add"), "peer", "Real contact") + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw["unselected"][FieldCodec.field_plan(Thread, "model").key] = 123 + registry = replace(captured, threads=ThreadRows.from_wire(raw)) + thread = registry.require("owner") + contacts, _ = comms.relationships._goal_contacts(registry, thread) + orders = {group: order for group in SortableRelationshipGroup.members_with(SortableRelationshipGroup)} + sent = order.sent_timestamps(comms.views, registry=registry) + inputs = RelationshipInputs( + registry, thread, (), + DeliveryScope.capture(thread.name, registry, comms.views.channels.catalog.read()), + comms.relationships.store.read().resolved(registry).collaborations, contacts, + MappingProxyType(orders), MappingProxyType(sent), + ) + original_orders, original_sent = dict(orders), dict(sent) + groups = {type(group): group for group in RelationshipGroup.capture(inputs, comms.views)} + for group_type, target in ((ParentRelationshipGroup, "origin"), + (ChildrenRelationshipGroup, "child"), + (CollaboratingRelationshipGroup, "peer")): + entry = groups[group_type].entries[target] + assert entry.available and entry.person.require_present().thread is registry.require(target) + selected = {person.thread.name for group in groups.values() + for entry in group.entries.values() for person in entry.person.views()} + assert selected == {"origin", "child", "peer"} + assert inputs.orders == original_orders and inputs.sent == original_sent + assert all(selected.order is order for group in groups.values() for selected in group.selectable()) + with pytest.raises(InvalidRegistryDocument): + registry.require("unselected") + + def test_concurrent_declaring_agents_do_not_lose_updates(tmp_path): comms = setup_wire(tmp_path) names = [f"worker-{index}" for index in range(8)] @@ -181,7 +254,9 @@ def test_deleted_peer_survives_unrelated_edit_and_explicit_remove(tmp_path): missing = collaboration_rows(comms)[0] assert missing.target == "peer", "Deleted identity must remain copyable" - assert missing.available is False and missing.person is None + assert missing.available is False and missing.person.views() == () + snapshot = comms.relationships.snapshot("owner") + assert FieldCodec.decode(type(snapshot), FieldCodec.encode(snapshot)) == snapshot assert missing.detail == original.note assert comms.relationships.collaborations("owner") == (original,) @@ -226,7 +301,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) row = collaboration_rows(comms)[0] - assert row.target == "peer" and row.person is None and not row.available + assert row.target == "peer" and row.person.views() == () and not row.available for action in ("add", "update"): with pytest.raises(ValueError): comms.relationships.edit( @@ -242,7 +317,7 @@ def test_reused_peer_name_does_not_rebind_or_overwrite_historical_work(tmp_path) ) assert new.peer_created != original.peer_created row = collaboration_rows(comms)[0] - assert row.available and row.person.thread.created_at == new.peer_created + assert row.available and row.person.require_present().thread.birth == new.peer_created assert collaboration_rows(comms, "peer")[0].target == "owner" @@ -300,7 +375,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path visible = collaboration_rows(comms)[0] assert ( visible.available - and visible.person.thread.incarnation == comms.registry.require("renamed-peer").incarnation + and visible.person.require_present().thread.incarnation == comms.registry.require("renamed-peer").incarnation ) assert visible.target == "renamed-peer" assert visible.sources == ("explicit", "goal_mention") @@ -317,7 +392,7 @@ def test_retained_alias_contact_without_projected_person_is_unavailable(tmp_path unavailable = collaboration_rows(comms)[0] assert unavailable.target == "renamed-peer" assert unavailable.sources == ("explicit", "goal_mention") - assert not unavailable.available and unavailable.person is None + assert not unavailable.available and unavailable.person.views() == () assert "Retained explicit contact" in unavailable.detail @@ -348,8 +423,9 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, kind="channel") ) is None - reused = replace(earlier.person, thread=replace( - earlier.person.thread, created_at=earlier.person.thread.created_at + 1 + person = earlier.person.require_present() + reused = replace(person, thread=replace( + person.thread, created_at=person.thread.created_at + 1 )) assert selected.current_entry( SortedInboundRelationshipGroup, replace(earlier, person=reused) @@ -366,3 +442,45 @@ class SortedInboundRelationshipGroup(SortableRelationshipGroup, InboundRelations assert FieldCodec.decode(type(reopened), FieldCodec.encode(reopened)) == reopened finally: RelationshipGroup.__registry__.pop(SortedInboundRelationshipGroup.declared_name) + + +def test_new_group_retains_its_one_original_source_through_complete_presentation(tmp_path: Path): + comms = setup_wire(tmp_path) + selections: list[RelationshipInputs] = [] + originals: list[NamedEntrySource] = [] + built: list[NamedEntrySource] = [] + + class CapturedEntrySource(NamedEntrySource): + __slots__ = () + + def entry(self, context: RelationshipContext) -> RelationshipEntry: + built.append(self) + assert context.registry is selections[0].registry + assert context.people[self.name].thread is context.registry.require(self.name) + with pytest.raises(TypeError): + context.people["unexpected"] = context.people[self.name] + return super().entry(context) + + class CapturedRelationshipGroup(RelationshipGroup): + title = "One original source" + + @classmethod + def sources(cls, inputs: RelationshipInputs) -> tuple[RelationshipEntrySource, ...]: + selections.append(inputs) + assert len(selections) == 1 + source = CapturedEntrySource( + CapturedEntrySource.canonical_target("peer", inputs.registry), + ) + originals.append(source) + return (source,) + + try: + snapshot = comms.relationships.snapshot("owner") + group = snapshot.groups_by_type[CapturedRelationshipGroup] + assert len(selections) == len(originals) == len(built) == 1 + assert built[0] is originals[0] + entry = group.entries["peer"] + assert entry.person.require_present().thread is selections[0].registry.require("peer") + assert entry.available and entry.target == originals[0].name + finally: + RelationshipGroup.__registry__.pop(CapturedRelationshipGroup.declared_name) diff --git a/tests/test_s4_read_characterization.py b/tests/test_s4_read_characterization.py index 290ebc5e03..56ccfe7f74 100644 --- a/tests/test_s4_read_characterization.py +++ b/tests/test_s4_read_characterization.py @@ -17,7 +17,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa comms.registry.declare(Thread('worker', frozenset({'team'}), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("worker", "#team", "painted").message - page = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + page = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) original = comms.registry.require(viewer) @@ -27,7 +30,10 @@ def test_replaced_human_incarnation_rejects_painted_proof_and_prior_reads(tmp_pa assert message.seq not in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) with pytest.raises(DMScopeChanged): comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) - refreshed = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + refreshed = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) comms.views.mark_page_read(refreshed, worktree=Checkout(tmp_path)) assert message.seq in comms.bus.reads.cursor(viewer, comms.registry.snapshot()).for_conversation(ReadLedger.conversation(message, comms.registry.snapshot())) diff --git a/tests/test_sidebar_observation.py b/tests/test_sidebar_observation.py index 750f599054..207e219d32 100644 --- a/tests/test_sidebar_observation.py +++ b/tests/test_sidebar_observation.py @@ -4,17 +4,20 @@ import pickle from collections.abc import Mapping from dataclasses import fields, replace +from pathlib import Path import pytest -from agent_comms.registry_document import ThreadRows +from agent_comms.registry_document import InvalidRegistryDocument, ThreadRows -from agent_comms.errors import RelationViolationError -from agent_comms.field_codec import MalformedRecord +from agent_comms.errors import RelationViolationError, UnregisteredThreadError +from agent_comms.field_codec import FieldCodec, MalformedRecord, ThreadLookup +from agent_comms.session_file import BusSessionLocation 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.thread_status import RunningThreadStatus, StoppedThreadStatus +from agent_comms.thread_text import GivenThreadText from agent_comms.ui_model.sidebar import SidebarModel from agent_comms.ui_model.unread import ExactUnread, UnobservedUnread from agent_comms.threads import Thread @@ -58,10 +61,20 @@ def test_registry_snapshot_constructor_and_pickle_own_immutable_maps(comms, decl supplied = dict(captured.threads) snapshot = replace(captured, threads=ThreadRows.from_threads(supplied)) supplied.clear() + assert snapshot.lookup_names # Derived cached views must not enter the pickle. for restored in (snapshot, pickle.loads(pickle.dumps(snapshot, protocol=5))): assert restored.purpose == captured.purpose assert restored.threads[original.name] == original - assert restored.born(original.incarnation.require_recorded().lookup) == original + lookup = original.incarnation.require_recorded().lookup + assert restored.born(lookup) == original + assert dict(restored.lookup_names) == {lookup: original.name} + assert list(restored.lookup_names.items()) == [(lookup, original.name)] + assert list(restored.lookup_names.values()) == [original.name] + assert restored.lookup_names == {lookup: original.name} + with pytest.raises(TypeError): + restored.lookup_names[lookup] = original.name + with pytest.raises(TypeError): + del restored.lookup_names[lookup] for declaration in fields(restored): value = getattr(restored, declaration.name) if isinstance(value, Mapping): @@ -92,7 +105,13 @@ def test_snapshot_constructor_retains_namespace_validation_and_derives_birth_ind def test_current_reference_resolves_original_birth_after_registry_rename(comms, declare_reference): original = declare_reference(status=RunningThreadStatus()) + captured = comms.registry.snapshot() + before = captured.lookup_names + lookup = original.incarnation.lookup comms.registry.rename(original.name, "renamed") + assert comms.registry.snapshot().born(lookup).name == "renamed" + assert before[lookup] == original.name + assert captured.born(lookup) == original snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references current = references.thread(original.incarnation) @@ -104,9 +123,18 @@ def test_current_reference_resolves_original_birth_after_registry_rename(comms, def test_recreated_thread_reference_resolves_new_birth_not_same_name(comms, declare_reference): original = declare_reference() + captured = comms.registry.snapshot() before = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references comms.threads.delete(original.name) + deleted = comms.registry.snapshot().lookup_names recreated = declare_reference(original.name) + current = comms.registry.snapshot() + assert original.incarnation.lookup not in deleted + assert original.incarnation.lookup not in current.lookup_names + assert current.born(recreated.incarnation.lookup) == recreated + assert captured.born(original.incarnation.lookup) == original + with pytest.raises(UnregisteredThreadError): + current.born(original.incarnation.lookup) snapshot = comms.views.viewer_snapshot(Checkout(comms.root.parent)) references = pickle.loads(pickle.dumps(snapshot, protocol=5)).references assert recreated.incarnation != original.incarnation @@ -127,6 +155,209 @@ def test_current_reference_membership_and_lookup_share_the_retained_registry(com restored.targets["unowned"] = reference +@pytest.mark.parametrize("decoded", (False, True)) +def test_captured_lookup_acquires_and_binds_only_requested_complete_rows( + comms, declare_reference, decoded, +): + originals = tuple(declare_reference(f"lookup-{number}") for number in range(12)) + selected = replace( + originals[0], session_file=BusSessionLocation(Path("lookup-selected.jsonl")), + ) + invalid = originals[-1] + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[selected.name] = FieldCodec.encode_fields(selected) + raw[invalid.name][FieldCodec.field_plan(Thread, "model").key] = 123 + rows = ThreadRows.from_wire(raw) + rows.bind(comms.registry.store._bind_thread) + if decoded: + rows[selected.name] = selected # A genuine write supplies the row's actual Thread. + snapshot = replace(captured, threads=rows) + decoded_before = frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) + expected = {thread.name: thread.incarnation.lookup for thread in originals} + + lookup = snapshot.lookup_names + assert lookup + assert len(lookup) == len(originals) + assert tuple(lookup) == tuple(expected[name] for name in snapshot.threads) + assert set(lookup.keys()) == set(expected.values()) + assert all(key in lookup and key in lookup.keys() for key in expected.values()) + missing = ThreadLookup.mint() + assert missing not in lookup and missing not in lookup.keys() + assert lookup.get(missing) is None + with pytest.raises(KeyError) as caught: + lookup[missing] + assert caught.value.args == (missing,) + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before + + acquired = snapshot.born(selected.incarnation.lookup) + assert acquired is snapshot.require(selected.name) + assert acquired.session_file == selected.session_file + assert acquired.session_file.bound_to(comms.root) is acquired.session_file + assert lookup.get(selected.incarnation.lookup) == selected.name + assert snapshot.born(selected.incarnation.lookup) is acquired + assert frozenset(name for name, row in snapshot.threads._rows.items() + if "decoded" in row.__dict__) == decoded_before | {selected.name} + with pytest.raises(InvalidRegistryDocument): + snapshot.born(invalid.incarnation.lookup) + with pytest.raises(InvalidRegistryDocument): + {key: snapshot.born(key) for key in lookup} + with pytest.raises(UnregisteredThreadError): + comms.registry.require_lookup(missing) + + +@pytest.mark.parametrize("change", ("add", "delete", "rebirth")) +@pytest.mark.parametrize("new_first", (False, True)) +def test_lookup_membership_stays_with_empty_and_unprimed_cuts( + comms, declare_reference, change, new_first, +): + original = declare_reference() + template = comms.registry.snapshot() + rows = ThreadRows.from_wire( + {} if change == "add" else {original.name: FieldCodec.encode_fields(original)} + ) + before = replace(template, threads=rows) + # Even an obtained empty view must remain empty after the shared rows change. + empty_view = before.lookup_names if change == "add" else None + if change == "delete": + del rows[original.name] + expected_after = () + else: + replacement = original + if change == "rebirth": + comms.threads.delete(original.name) + replacement = declare_reference(original.name) + assert replacement.incarnation != original.incarnation + rows[replacement.name] = replacement + expected_after = (replacement,) + after = replace(template, threads=rows) + expected_before = () if change == "add" else (original,) + cuts = ((before, expected_before), (after, expected_after)) + candidates = {original.incarnation.lookup, ThreadLookup.mint()} + candidates.update(thread.incarnation.lookup for thread in expected_after) + for snapshot, expected in reversed(cuts) if new_first else cuts: + lookup = snapshot.lookup_names + names = {thread.incarnation.lookup: thread.name for thread in expected} + assert tuple(lookup) == tuple(names) + assert tuple(lookup.keys()) == tuple(names) + assert len(lookup) == len(snapshot.threads) == len(names) + assert dict(lookup) == names + for key in candidates: + assert (key in lookup) == (key in names) + assert lookup.get(key) == names.get(key) + if key in names: + assert lookup[key] == names[key] + assert snapshot.born(key) is snapshot.threads[names[key]] + else: + with pytest.raises(KeyError): + lookup[key] + with pytest.raises(UnregisteredThreadError): + snapshot.born(key) + if empty_view is not None: + assert not empty_view and not tuple(empty_view) + + +@pytest.mark.parametrize("decoded", (False, True)) +def test_current_reference_acquires_only_captured_incarnations(comms, declare_reference, decoded): + original = declare_reference(status=RunningThreadStatus()) + unread = declare_reference("unread") + comms.registry.rename(original.name, "renamed") + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[unread.name][FieldCodec.field_plan(Thread, "model").key] = 123 + snapshot = replace(captured, threads=ThreadRows.from_wire(raw)) + if decoded: + snapshot.require("renamed") + + references = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + for _ in range(100): + repeated = CurrentDisplayReferences.from_registry(str(comms.root), snapshot, ("#comms",)) + assert repeated.members["renamed"] is references.members["renamed"] + assert repeated.members["unread"] is references.members["unread"] + with pytest.raises(InvalidRegistryDocument): + snapshot.require(unread.name) + assert references.members[original.name].name == "renamed" + assert references.members["renamed"].wire_birth == original.incarnation.wire_birth + assert references.find(f"@{original.name}")[0].reference.name == "renamed" + assert references.find("#comms")[0].reference.name == "#comms" + + +@pytest.mark.parametrize("entry", ( + FieldCodec.field_plan(Thread, "goal"), FieldCodec.field_plan(Thread, "role"), +)) +def test_cold_shape_and_birth_capture_do_not_acquire_unrequested_goal_fields( + comms, declare_reference, entry, +): + original = declare_reference() + captured = comms.registry.snapshot() + raw = captured.threads.to_wire() + raw[original.name][entry.key] = [] + rows = ThreadRows.from_wire(raw) + assert all("birth_record" not in row.__dict__ for row in rows._rows.values()) + assert all("decoded" not in row.__dict__ for row in rows._rows.values()) + snapshot = replace(captured, threads=rows) + assert snapshot.incarnation(original.name) == original.incarnation + assert original.incarnation.lookup in snapshot.lookup_names + assert all("decoded" not in row.__dict__ for row in snapshot.threads._rows.values()) + with pytest.raises(MalformedRecord): + snapshot.threads.determining(original.name) + with pytest.raises(InvalidRegistryDocument): + snapshot.born(original.incarnation.lookup) + + +def test_lookup_metadata_is_derived_from_each_unchanged_captured_row_set( + comms, declare_reference, +): + original = declare_reference(status=RunningThreadStatus()) + declare_reference("unrelated") + before = comms.registry.snapshot() + lookup = original.incarnation.lookup + original_index = before.lookup_names + changed = comms.registry.update_thread( + original.name, lambda thread: replace(thread, task=GivenThreadText("data-only edit")), + ) + after = comms.registry.snapshot() + assert after.born(lookup) == changed + assert after.lookup_names == original_index + assert original_index[lookup] == original.name + assert before.born(lookup) == original + assert tuple(after.lookup_names) == tuple(after.incarnation(name).require_recorded().lookup + for name in after.threads) + comms.registry.rename(original.name, "renamed") + renamed = comms.registry.snapshot() + assert renamed.born(lookup).name == "renamed" + assert original_index[lookup] == original.name + assert after.born(lookup).name == original.name + assert tuple(renamed.lookup_names) == tuple(renamed.incarnation(name).require_recorded().lookup + for name in renamed.threads) + + +@pytest.mark.parametrize("entry,value", ( + (FieldCodec.field_plan(Thread, "name"), "invalid name"), + (FieldCodec.field_plan(Thread, "created_at"), []), + (FieldCodec.field_plan(Thread, "goal"), []), + (FieldCodec.field_plan(Thread, "role"), []), +)) +def test_captured_reference_rows_refuse_malformed_determining_fields(declare_reference, entry, value): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + raw[entry.key] = value + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: raw}).determining(original.name) + + +def test_captured_reference_rows_refuse_unknown_or_absent_required_fields(declare_reference): + original = declare_reference() + raw = FieldCodec.encode_fields(original) + missing_birth = dict(raw) + del missing_birth[FieldCodec.field_plan(Thread, "created_at").key] + for malformed in ({**raw, "undeclared_field": "refuse"}, missing_birth): + with pytest.raises(MalformedRecord): + ThreadRows.from_wire({original.name: malformed}).determining(original.name) + + def test_current_reference_source_projection_keeps_historical_identity(comms, declare_reference): original = declare_reference() references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index 7a0afa1882..4edfb0275b 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -257,7 +257,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): comms.registry.declare(Thread('owner', frozenset(), Checkout(tmp_path))) viewer = comms.messaging.user_identity(Checkout(tmp_path)).name message = comms.messaging.send("owner", viewer, "already painted").message - page = comms.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = comms.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) comms.views.mark_page_read(page, worktree=Checkout(tmp_path)) path = comms.bus.reads.path document = FieldCodec.decode(ReadDocument, json.loads(path.read_text())) @@ -271,7 +274,10 @@ def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): conversation = reopened.bus.reads.conversation(message, snapshot) assert message.seq in reopened.bus.reads.cursor(viewer, snapshot).for_conversation(conversation) next_message = reopened.messaging.send("owner", viewer, "new paint").message - page = reopened.views.dm_display_page(ThreadTarget("owner"), worktree=Checkout(tmp_path)) + page = reopened.views.dm_display_view( + ThreadTarget('owner'), + worktree=Checkout(tmp_path), + ).page(reopened.views.presentation) reopened.views.mark_page_read(page, worktree=Checkout(tmp_path)) seen = reopened.bus.reads.cursor(viewer, reopened.registry.snapshot()).for_conversation(conversation) assert all(sequence in seen for sequence in (message.seq, next_message.seq)) diff --git a/tests/test_user_thread_lookup.py b/tests/test_user_thread_lookup.py index 4222093b6a..cbe690e53a 100644 --- a/tests/test_user_thread_lookup.py +++ b/tests/test_user_thread_lookup.py @@ -41,9 +41,14 @@ def test_channel_and_dm_history_keep_the_human_as_sender(tmp_path): human = comms.registry.user_thread().existing()[0] assert channel.sender == direct.sender == human.name assert channel.sender_role is direct.sender_role is UserThreadRole - channel_page = comms.views.channel_display_page( - '#comms', viewer=comms.views.display_viewer(Checkout(tmp_path))) - direct_page = comms.views.dm_display_page(ThreadTarget('peer'), worktree=Checkout(tmp_path)) + channel_page = comms.views.channel_display_view( + '#comms', + viewer=comms.views.display_viewer(Checkout(tmp_path)), + ).page(comms.views.presentation) + direct_page = comms.views.dm_display_view( + ThreadTarget('peer'), + worktree=Checkout(tmp_path), + ).page(comms.views.presentation) assert channel.message_id in {message.message_id for message in channel_page.messages} assert direct.message_id in {message.message_id for message in direct_page.messages} assert all(message.sender == human.name for message in (*channel_page.messages, *direct_page.messages)) diff --git a/tests/test_view_unread.py b/tests/test_view_unread.py index 834c3215fd..421673b1f9 100644 --- a/tests/test_view_unread.py +++ b/tests/test_view_unread.py @@ -98,7 +98,10 @@ def test_any_mode_rejects_stale_painted_page_after_participant_joins(tmp_path): comms.channels.set_channel_any_mode("#team", True) comms.messaging.send("carol", "dave", "hidden old DM").message comms.messaging.send("alice", "bob", "painted DM").message - painted = comms.views.channel_display_page("#team", viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name)) + painted = comms.views.channel_display_view( + '#team', + viewer=NamedViewer(comms.messaging.user_identity(Checkout(tmp_path)).name), + ).page(comms.views.presentation) assert [message.seq for message in painted.messages] == [2] comms.channels.update_tags("carol", add=frozenset({"team"})) @@ -131,5 +134,7 @@ def test_crashed_sender_reopen_does_not_hide_unpainted_any_mode_dm(): assert child.returncode == 9 reopened = wire(root) reopened.channels.set_channel_any_mode("#team", True) - assert [message.seq for message in list(reopened.views.channel_display_page("#team").messages)] == [1, 2] + assert [message.seq for message in list(reopened.views.channel_display_view( + '#team', + ).page(reopened.views.presentation).messages)] == [1, 2] assert reopened.views.viewer_snapshot(Checkout(root)).channel_unread["#team"].count == 2