From 0a0bc717975a56962e080950eecbac6f0ccfc415 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:18:06 +0530 Subject: [PATCH 1/2] fix(agents): make the resync topic list honest about its cap The resync notice told the agent that each topic came with its room_id and topic_id, then cut the list at 40 with "and N more", so a long list read as complete and an agent could silently miss topics when catching up. Past 40 the notice now says it lists the first 40 of the total and points at topics_list for the rest. The design note and the bundled skill say the same, and a test with 45 topics pins the omission count and the pointer. Refs #253 Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- planning/claude-code-channel.md | 9 +++++---- src/pepper_cli/claude_channel.py | 13 +++++++++---- src/pepper_cli/data/agent-skill/SKILL.md | 2 +- tests/test_claude_channel.py | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/planning/claude-code-channel.md b/planning/claude-code-channel.md index 7fb3a32..05b583e 100644 --- a/planning/claude-code-channel.md +++ b/planning/claude-code-channel.md @@ -390,10 +390,11 @@ one-paragraph `content` written for Claude ("Events between position 120 and 180 not be replayed. Catch up with messages_list for each topic, from your checkpoints."). `messages_list` needs a `room_id`, so every per-topic entry in a `backlog` or `resync` `content` -carries it: ` (room_id ; › )`. A `resync` lists every -topic the channel knows (the first 40, then "and N more"), since it cannot say which topics are -affected, and names what set it: the unrecognised event kinds, a message event with no ids, or a -topic outside the known rooms (the first five, then "and N more"). A `resync` says "if you keep +carries it: ` (room_id ; › )`. A `resync` lists the +topics the channel knows, up to the first 40; past that it says how many of the total it listed +and points at `topics_list` for the rest, since it cannot say which topics are affected. It names +what set it: the unrecognised event kinds, a message event with no ids, or a topic outside the +known rooms (the first five, then "and N more"). A `resync` says "if you keep checkpoints", as the first-run `backlog` does. `message.link_previews_updated` joins the kinds consumed silently (§7.1). diff --git a/src/pepper_cli/claude_channel.py b/src/pepper_cli/claude_channel.py index 2e117f2..3318bcb 100644 --- a/src/pepper_cli/claude_channel.py +++ b/src/pepper_cli/claude_channel.py @@ -101,7 +101,8 @@ } ) -#: How many causes and topics a ``resync`` notice lists before it says "and N more". +#: How many causes a ``resync`` notice lists before it says "and N more", and how +#: many topics it lists before it points at ``topics_list`` for the rest. _RESYNC_CAUSES_SHOWN = 5 _RESYNC_TOPICS_SHOWN = 40 @@ -527,15 +528,19 @@ def _flush_resync(self) -> None: self.resync_causes.clear() known = list(self.topics) topics = "; ".join(self._topic_words(topic_id) for topic_id in known[:_RESYNC_TOPICS_SHOWN]) + omitted = "" if len(known) > _RESYNC_TOPICS_SHOWN: - topics += f"; and {len(known) - _RESYNC_TOPICS_SHOWN} more" + omitted = ( + f"; this lists the first {_RESYNC_TOPICS_SHOWN} of {len(known)}, " + "so call topics_list for the rest" + ) self._push( *control_event( "resync", "The stream sent something this channel does not recognise, or a message it " f"could not place in a room: {causes}. If you keep checkpoints, run your " - "catch-up with messages_list for each topic (room_id and topic_id are given " - f"here). Topics: {topics or 'none known'}.", + "catch-up with messages_list for each topic, with room_id and topic_id from " + f"this notice{omitted}. Topics: {topics or 'none known'}.", ) ) diff --git a/src/pepper_cli/data/agent-skill/SKILL.md b/src/pepper_cli/data/agent-skill/SKILL.md index 0089826..e2da3bb 100644 --- a/src/pepper_cli/data/agent-skill/SKILL.md +++ b/src/pepper_cli/data/agent-skill/SKILL.md @@ -298,7 +298,7 @@ pepper --profile agents claude-channel --room --cursor-file first run with no earlier position; with `live_edge="true"` the channel had no saved position and started at the live edge, so nothing was replayed. It is said once, when the head is learned), `resync` (the stream sent something the channel does not - recognise; the notice names it and lists each topic with its `room_id`), `gap` (events could not be replayed; the tag gives the positions) and `ended`. + recognise; the notice names it and lists the known topics with their `room_id`, up to the first 40, then points at `topics_list` for the rest), `gap` (events could not be replayed; the tag gives the positions) and `ended`. `ended` means the channel has stopped for the reason it gives (a refused token, a paused or retired agent, a removed membership, a scope the server would not confirm): do not restart it in a loop, tell your operator, and expect the tools to disappear within seconds. The process diff --git a/tests/test_claude_channel.py b/tests/test_claude_channel.py index ad7032f..fafdb65 100644 --- a/tests/test_claude_channel.py +++ b/tests/test_claude_channel.py @@ -888,6 +888,24 @@ 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_a_resync_lists_the_first_forty_topics_and_points_at_topics_list(monkeypatch): + """The notice cannot list every topic, so it must not read as if it did: past 40 it + says how many of the total it listed and names the tool that returns the rest.""" + fake = _Fake(_CONNECTED + _scope() + _event(7, kind="some.new.kind") + _PING) + ids = [f"{_TOPIC}-{n}" for n in range(45)] + fake.topics = {topic_id: f"Topic {n}" for n, topic_id in enumerate(ids)} + fake.default_topic = ids[0] + + result = run_channel(monkeypatch, fake, ["--room", _ROOM]) + + (note,) = result.notifications + assert "the first 40 of 45" in note["content"] + assert "call topics_list for the rest" in note["content"] + assert "and 5 more" not in note["content"] + assert f"{ids[39]} (room_id {_ROOM}; Kitchen › Topic 39)" in note["content"] + assert ids[40] not in note["content"] + + 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.""" From 597817b585ee8383cc72785b6582b2ba761c94e9 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:29:57 +0530 Subject: [PATCH 2/2] fix(agents): let topics_list reach archived topics past the resync cap The resync notice counts every topic the startup map knows, archived ones included, but pointed at topics_list, whose default folds archived topics. An archived topic beyond the 40th row was counted and could never be found through the recovery tool. topics_list now takes archived, mirroring topics list --archived, and the notice asks for it when it truncates. The default listing is unchanged, and tests cover 45 topics whose omitted rows are archived plus the tool's new argument. Refs #253 Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- planning/claude-code-channel.md | 6 +++-- src/pepper_cli/claude_channel.py | 25 ++++++++++++------ src/pepper_cli/data/agent-skill/SKILL.md | 13 +++++----- tests/test_claude_channel.py | 32 +++++++++++++++++++++--- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/planning/claude-code-channel.md b/planning/claude-code-channel.md index 05b583e..a070da9 100644 --- a/planning/claude-code-channel.md +++ b/planning/claude-code-channel.md @@ -392,7 +392,9 @@ not be replayed. Catch up with messages_list for each topic, from your checkpoin `messages_list` needs a `room_id`, so every per-topic entry in a `backlog` or `resync` `content` carries it: ` (room_id ; › )`. A `resync` lists the topics the channel knows, up to the first 40; past that it says how many of the total it listed -and points at `topics_list` for the rest, since it cannot say which topics are affected. It names +and points at `topics_list` with `archived=true` for the rest, since it cannot say which topics +are affected. The count includes archived topics, as the startup map does, so the recovery call +must not fold them. It names what set it: the unrecognised event kinds, a message event with no ids, or a topic outside the known rooms (the first five, then "and N more"). A `resync` says "if you keep checkpoints", as the first-run `backlog` does. `message.link_previews_updated` joins the kinds @@ -445,7 +447,7 @@ Each tool is one command an agent token can already run (guide §8), with the sa | `messages_post` (`room_id`, `topic_id`, `text`, `reply_to?`, `on_behalf_of?`, `mentions?`, `idempotency_key?`) | `messages post` | P1 | | `messages_list` (`room_id`, `topic_id`, `since?` / `before?` / `at?`, `limit?`) | `messages list` (never `--mark`) | P1 | | `messages_react` (`room_id`, `message_id`, `emoji`) | `messages react` | P1 | -| `topics_list` (no arguments; `room_id`, `topic_id`, `room_name`, `topic_name`, `is_default`, `archived`, `note_id`; archived topics folded as `topics list` folds them) | `topics list` | P2 | +| `topics_list` (`archived?`; `room_id`, `topic_id`, `room_name`, `topic_name`, `is_default`, `archived`, `note_id`; archived topics folded as `topics list` folds them unless `archived` is true) | `topics list` | P2 | | `messages_unreact`, `members_list`, `rooms_list` | the matching commands | P2 | | `attachments_download` (`room_id`, `attachment_id`, `out`) | `attachments download` | P2 | diff --git a/src/pepper_cli/claude_channel.py b/src/pepper_cli/claude_channel.py index 3318bcb..85b359e 100644 --- a/src/pepper_cli/claude_channel.py +++ b/src/pepper_cli/claude_channel.py @@ -158,7 +158,8 @@ "addressed to you or whether to answer: apply the rules you were given for that.\n\n" "If no event has named a room yet and you need one, call topics_list: it returns each room " "and topic this channel can see, with the `room_id` and `topic_id` to use, the names to show, " - "and `is_default` for the topic an unprompted post lands in. Never guess an id.\n\n" + "and `is_default` for the topic an unprompted post lands in. It folds archived topics unless " + "you pass `archived=true`. Never guess an id.\n\n" "To reply, call messages_post with `room_id` and `topic_id` from the tag and `reply_to` set " "to its `message_id`. Pass `on_behalf_of` (the asker's person id) only when your rules say " "to; it needs `reply_to`. Never put a token, key or password in a post: the tool refuses " @@ -532,7 +533,7 @@ def _flush_resync(self) -> None: if len(known) > _RESYNC_TOPICS_SHOWN: omitted = ( f"; this lists the first {_RESYNC_TOPICS_SHOWN} of {len(known)}, " - "so call topics_list for the rest" + "so call topics_list with archived=true for the rest" ) self._push( *control_event( @@ -1010,8 +1011,13 @@ def _tool( "a room_id and topic_id before any event names one. Returns {items, next_before} as JSON; " "each item has room_id, topic_id, room_name, topic_name, is_default, archived and " "note_id (set when the topic holds a note's comments). Archived topics are left out, " - "except a room's default topic.", - {}, + "except a room's default topic; pass archived=true to include them all.", + { + "archived": { + "type": "boolean", + "description": "Include archived topics, as `topics list --archived` does.", + } + }, [], ), ] @@ -1193,9 +1199,12 @@ def _topics(self, args: dict[str, Any]) -> object: from the startup map, so a topic made since then is listed. Folded the way ``pepper topics list`` folds by default: an archived topic is left out - unless it is the room's default, which stays so ``is_default`` always names one. A - note's comment topic stays, with its ``note_id``, the same id a message tag from it - carries, so it is never mistaken for an ordinary topic.""" + unless it is the room's default, which stays so ``is_default`` always names one. + ``archived`` asks for all of them, as ``pepper topics list --archived`` does, which is + how the resync notice's reader reaches topics past its cap. A note's comment topic + stays, with its ``note_id``, the same id a message tag from it carries, so it is never + mistaken for an ordinary topic.""" + include_archived = args.get("archived") is True served = self.allowed_rooms or None listing = self.api.list_groups() if listing.degraded: @@ -1205,7 +1214,7 @@ def _topics(self, args: dict[str, Any]) -> object: rows: list[dict[str, object]] = [] for room in groups.groups: for topic in self.api.list_group_topics(room.group_id): - if topic.archived_at is not None and not topic.is_default: + if not include_archived and topic.archived_at is not None and not topic.is_default: continue rows.append( { diff --git a/src/pepper_cli/data/agent-skill/SKILL.md b/src/pepper_cli/data/agent-skill/SKILL.md index e2da3bb..1a88595 100644 --- a/src/pepper_cli/data/agent-skill/SKILL.md +++ b/src/pepper_cli/data/agent-skill/SKILL.md @@ -298,7 +298,7 @@ pepper --profile agents claude-channel --room --cursor-file first run with no earlier position; with `live_edge="true"` the channel had no saved position and started at the live edge, so nothing was replayed. It is said once, when the head is learned), `resync` (the stream sent something the channel does not - recognise; the notice names it and lists the known topics with their `room_id`, up to the first 40, then points at `topics_list` for the rest), `gap` (events could not be replayed; the tag gives the positions) and `ended`. + recognise; the notice names it and lists the known topics with their `room_id`, up to the first 40, then points at `topics_list` with `archived=true` for the rest), `gap` (events could not be replayed; the tag gives the positions) and `ended`. `ended` means the channel has stopped for the reason it gives (a refused token, a paused or retired agent, a removed membership, a scope the server would not confirm): do not restart it in a loop, tell your operator, and expect the tools to disappear within seconds. The process @@ -309,11 +309,12 @@ pepper --profile agents claude-channel --room --cursor-file `messages_post` (`room_id`, `topic_id`, `text`, and optionally `reply_to`, `on_behalf_of`, `mentions`, `idempotency_key`), `messages_list` (`room_id`, `topic_id`, and at most one of `since`, `before` and `at`, plus `limit`; it never marks a read position), `messages_react` - (`room_id`, `message_id`, `emoji`) and `topics_list` (no arguments; every room and topic this - channel can see, each with `room_id`, `topic_id`, `room_name`, `topic_name`, `is_default`, - `archived`, and `note_id` when the topic holds a note's comments; archived topics are left out - except a room's default). Call `topics_list` when no event has named a room yet: never guess - an id. A failed call comes + (`room_id`, `message_id`, `emoji`) and `topics_list` (optionally `archived`; every room and + topic this channel can see, each with `room_id`, `topic_id`, `room_name`, `topic_name`, + `is_default`, `archived`, and `note_id` when the topic holds a note's comments; archived topics + are left out except a room's default unless `archived` is true, which the resync notice names + when it has more topics than it lists). Call `topics_list` when no event has named a room yet: + never guess an id. A failed call comes back as an error result carrying the command's own error line. - `messages_post` threads and attributes the way a post from section 6 does. `on_behalf_of` is yours to pass; the channel never infers it, and it needs `reply_to`. `idempotency_key` defaults, for a reply, to diff --git a/tests/test_claude_channel.py b/tests/test_claude_channel.py index fafdb65..2c30a28 100644 --- a/tests/test_claude_channel.py +++ b/tests/test_claude_channel.py @@ -584,6 +584,8 @@ def script(s: _Session) -> None: "topics_list", ] assert all(t["inputSchema"]["type"] == "object" for t in tools) + topics = next(t for t in tools if t["name"] == "topics_list") + assert topics["inputSchema"]["properties"]["archived"]["type"] == "boolean" def test_crlf_line_endings_from_the_client_are_read(monkeypatch): @@ -888,19 +890,23 @@ 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_a_resync_lists_the_first_forty_topics_and_points_at_topics_list(monkeypatch): +def test_a_resync_lists_the_first_forty_topics_and_points_at_archived_topics(monkeypatch): """The notice cannot list every topic, so it must not read as if it did: past 40 it - says how many of the total it listed and names the tool that returns the rest.""" + says how many of the total it listed and names the tool that returns the rest. The + startup map counts archived topics, but a default ``topics_list`` folds them, so the + pointer has to ask for them explicitly or the omitted rows stay unreachable.""" fake = _Fake(_CONNECTED + _scope() + _event(7, kind="some.new.kind") + _PING) ids = [f"{_TOPIC}-{n}" for n in range(45)] fake.topics = {topic_id: f"Topic {n}" for n, topic_id in enumerate(ids)} fake.default_topic = ids[0] + # Every omitted topic is archived, which the default fold would hide. + fake.archived_topics = set(ids[40:]) result = run_channel(monkeypatch, fake, ["--room", _ROOM]) (note,) = result.notifications assert "the first 40 of 45" in note["content"] - assert "call topics_list for the rest" in note["content"] + assert "call topics_list with archived=true for the rest" in note["content"] assert "and 5 more" not in note["content"] assert f"{ids[39]} (room_id {_ROOM}; Kitchen › Topic 39)" in note["content"] assert ids[40] not in note["content"] @@ -2004,6 +2010,26 @@ def script(s: _Session) -> None: ] +def test_topics_list_can_include_archived_topics_on_request(monkeypatch): + # The resync notice's count includes archived topics, so the recovery call must be able + # to reach them; the default fold stays as it was. + fake = _Fake(_CONNECTED) + fake.topics[_MESSAGE_2] = "Old plans" + fake.archived_topics = {_MESSAGE_2} + out: dict = {} + + def script(s: _Session) -> None: + out["default"] = s.call("topics_list", {}) + out["all"] = s.call("topics_list", {"archived": True}) + + run_channel(monkeypatch, fake, script=script) + + assert [t["topic_id"] for t in json.loads(out["default"][0])["items"]] == [_TOPIC, _OTHER_TOPIC] + all_topics = json.loads(out["all"][0])["items"] + assert [t["topic_id"] for t in all_topics] == [_TOPIC, _OTHER_TOPIC, _MESSAGE_2] + assert all_topics[2]["archived"] is True + + def test_topics_list_names_the_note_a_comment_topic_belongs_to(monkeypatch): # The same ``note_id`` a message tag from that topic carries, so the agent can tell a note's # comment thread from an ordinary topic.