Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions planning/claude-code-channel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<topic_id> (room_id <room_id>; <room name> › <topic name>)`. 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: `<topic_id> (room_id <room_id>; <room name> › <topic name>)`. 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).

Expand Down Expand Up @@ -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 |

Expand Down
36 changes: 25 additions & 11 deletions src/pepper_cli/claude_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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"
)
Comment thread
vycari-review[bot] marked this conversation as resolved.
Resolved
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'}.",
)
)

Expand Down Expand Up @@ -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.",
}
},
[],
),
]
Expand Down Expand Up @@ -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:
Expand All @@ -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(
{
Expand Down
13 changes: 7 additions & 6 deletions src/pepper_cli/data/agent-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ pepper --profile <name> agents claude-channel --room <room> --cursor-file <path>
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
Expand All @@ -309,11 +309,12 @@ pepper --profile <name> agents claude-channel --room <room> --cursor-file <path>
`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
Expand Down
44 changes: 44 additions & 0 deletions tests/test_claude_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand 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.
Expand Down
Loading