diff --git a/src/pepper_cli/claude_channel.py b/src/pepper_cli/claude_channel.py index 2e117f2..4694d3a 100644 --- a/src/pepper_cli/claude_channel.py +++ b/src/pepper_cli/claude_channel.py @@ -499,7 +499,6 @@ def _flush_backlog(self) -> None: self._push(content, meta) self.skipped.clear() self.skipped_times.clear() - self.first_run = False def _live_edge(self) -> None: """Tell Claude, once, that the channel started at the live edge: there is no backlog to @@ -549,6 +548,9 @@ def _topic_words(self, topic_id: str) -> str: def _caught_up(self) -> None: self._flush_backlog() self._flush_resync() + # The catch-up is over whether or not anything was skipped. Left set when nothing was, + # ``first_run`` would go on suppressing resyncs for the rest of the process. + self.first_run = False self._persist() def _resync(self, cause: str) -> None: @@ -829,7 +831,10 @@ def _handle_frame(self, frame: api_client.FeedFrame) -> str | None: if frame.kind == "error": return self._frame_problem(frame) if frame.kind == "comment": - if frame.payload.get("comment") != "connected": + # ``: ping`` is the contract's replay boundary: the server sends one only while it + # has nothing past this position. ``: connected`` opens a connection, and any other + # comment (a proxy keepalive, a newer server's frame) says neither has happened. + if frame.payload.get("comment") == "ping": self._caught_up() return None scoped = self.config.room is not None diff --git a/tests/test_claude_channel.py b/tests/test_claude_channel.py index ad7032f..2e04fb3 100644 --- a/tests/test_claude_channel.py +++ b/tests/test_claude_channel.py @@ -888,6 +888,43 @@ def test_a_resync_names_its_cause_gives_room_ids_and_is_safe_on_a_first_run(monk assert f"{_TOPIC} (room_id {_ROOM}; Kitchen › Dinner)" in note["content"] +def test_an_old_event_replayed_after_a_first_run_still_owes_a_resync(monkeypatch): + """A first run whose window holds no messages pushes no backlog, so nothing but the completed + catch-up can clear ``first_run``. The stream then drops, and the reconnect replays an older + unrecognised event, which was missed and owes a resync. Left set, the flag takes it for + first-run history and drops it.""" + old = _now() - timedelta(days=3) + fake = _Fake( + _CONNECTED + _scope() + _PING, + 503, + _CONNECTED + _scope() + _event(7, kind="group.renamed", created=old) + _PING, + ) + + result = run_channel(monkeypatch, fake, ["--room", _ROOM, "--start", "beginning"]) + + assert result.kinds() == ["resync"] + + +def test_a_non_ping_comment_does_not_end_the_first_run_catch_up(monkeypatch): + """Only ``: ping`` marks replay completion; ``: connected`` opens the connection, and any + other comment (a proxy keepalive, a newer server's frame) proves neither. Left unfiltered, + such a comment ends the catch-up early, clears ``first_run``, flushes the backlog it owed, + and makes an old event replayed after it owe a spurious resync.""" + old = _now() - timedelta(days=3) + fake = _Fake( + _CONNECTED + _scope() + _event(3, created=old) + ": keepalive\n\n", + 503, + _CONNECTED + _scope() + _event(7, kind="group.renamed", created=old) + _PING, + ) + + result = run_channel(monkeypatch, fake, ["--room", _ROOM, "--start", "beginning"]) + + assert result.kinds() == ["backlog"] + (note,) = result.notifications + assert note["meta"]["first_run"] == "true" + assert "live_edge" not in note["meta"] + + def test_a_first_run_replay_of_old_room_events_owes_no_resync(monkeypatch): """The retained window on a first run holds old room events of every kind (a rename, an app install); none was missed, and the backlog already says to catch up."""