diff --git a/planning/claude-code-channel.md b/planning/claude-code-channel.md index 7fb3a32..a070da9 100644 --- a/planning/claude-code-channel.md +++ b/planning/claude-code-channel.md @@ -390,10 +390,13 @@ 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` 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 consumed silently (§7.1). @@ -444,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 2e117f2..85b359e 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 @@ -157,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 " @@ -527,15 +529,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 with archived=true 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'}.", ) ) @@ -1005,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.", + } + }, [], ), ] @@ -1188,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: @@ -1200,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 0089826..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 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` 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 ad7032f..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,6 +890,28 @@ 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_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. 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 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"] + + 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.""" @@ -1986,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.