Skip to content

fix(channels): the opened notices say what happens next, how servers differ, and what won't undo a close - #675

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/f356-channel-notice-wording
Oct 21, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/f356-channel-notice-wording

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

The notices that tell an agent a channel was opened for it now say what happens next, how servers differ in what an open channel brings, and what won't reopen a channel the agent closed. Two comments in agent.ts now say which reader takes a missing watermark which way.

Problem

Three sentences in channelOpenedNotices (src/framework.ts) left the agent without what it needed to act on them.

  • A subscription policy's notice said "This records the admission decision; channel_list shows current transport state." That is the host's view. The notice is written before the host asks the server to open the channels (reconcileChannels, src/mcpl/channel-registry.ts:3354), and the decision stays if that open fails. By our reading, the host asks again when the server next registers the channel: handleRegister (:1468) and a channels/changed that adds it (:1576) both reconcile it. The notice didn't say that, or what channel_list would show.
  • "What an open channel delivers to you depends on its server" didn't say how it depends, or where to find out. Servers differ:
    • discord-mcpl "delivers the messages posted in an open channel as they arrive";
    • slack-mcpl delivers mentions and DMs always, and other messages only from a conversation it's subscribed to. subscribe_channel and unsubscribe_channel change that. A mention that is the first message it takes from a conversation subscribes it, with a note saying so, unless the agent unsubscribed from it (server.ts:1703). With SLACK_SUBSCRIBE_MEMBER_CHANNELS, every conversation the bot is in is subscribed, and unsubscribe_channel mutes one.
  • "use channel_close; your choice outranks policy" didn't say which policy. A close is recorded as the agent's decision (source agent-tool, :4107). ensureInitialDesiredState lifts only a closed state nobody decided (default-closed, :3023), whether for the subscription policy or for a descriptor's initiallyOpen. So neither reopens a channel the agent closed.

markContextConsumed's comment in src/agent.ts said that "without a watermark nothing is ever treated as unread", and markContextShown's doc said the same of the shown watermark. That is the coalescer's reading. isUnreadStoredMessage and resolvePlacement take a missing consumed watermark as read, and isUnreadStoredMessage takes a missing shown watermark as shown. But deferredWriteReadOrGone and unreadNoticesForPrimary take a missing consumed watermark as unread.

Changes

  • A subscription policy's notice reads "The host opens it, and if that fails, tries again when its server next registers it; channel_list shows whether it's open yet." For several channels: "The host opens them, and if one doesn't open, tries again when its server next registers it; channel_list shows whether each is open yet." "Registers" is the verb channel_open's and channel_close's failure texts use for the same retry (laterClause, :722).
  • Where the agent's window holds the channel's traffic, a notice reads "What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that."
  • Where the agent can close the channel, a notice reads "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again." It says nothing about the agent's own messages, since on main a message into a closed channel still opens it.
  • The two agent.ts comments say which reader takes a missing watermark which way.
  • The notice builder's doc comment, and fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598's pending fragment (channel-notices-reach-who-can-close.fixed.md), no longer quote the replaced sentences.
  • A changelog fragment, channel-opened-notice-words.changed.md.

What each part is for

  • Each new sentence replaces one the agent couldn't act on, in the same place and under the same conditions. Nothing decides differently, and no notice is sent where it wasn't.
  • The plural form of the policy notice is there because a policy admits a server's channels in one batched notice, and the singular sentence would call them "it".
  • The comments cost nothing to keep, and the old ones were a claim a later reader could build on.
  • The changelog fragment tells hosts and module authors the words their agents now read, and what they replace, as CONTRIBUTING asks for behaviour a host or downstream consumer would notice. Editing fix(channels): a channel notice reaches whoever can close the channel, and a released agent's unread ones reach the primary #598's pending fragment keeps the same release's notes from quoting a sentence the notice no longer has.
  • Left out:
    • Naming the servers in the notice: what a server does is its own to change, so the notice names the two kinds and where to look.
    • The retry's condition: the retry also needs the host still to be allowed to direct that server's channels (reconcileChannels returns early otherwise, :3331). The notice is written only while the host is. Only a grant reduced afterwards makes it untrue, which stops the lifecycle of every channel that server has, and channel_list then shows the channel isn't open.
    • channel_open's and channel_close's descriptions, which say that what an open channel delivers, and what stops reaching you once it's closed, depends on its server: those are tool text, which changes on its own route.

+25 −15 lines in src/, comments included.

Tests

The tests that pin the notices' text now pin the new sentences:

  • channel-lifecycle-order:
    • "failed policy opening records intent once, with truthful resident text through a retry" fails the first open and checks the notice's words. Then the server registers the channel again: the server's fake receives a second open request, and the channel opens with no second notice. Then the test rebuilds the registry from the same store, and the channel opens again when the server adds it.
    • "the notices say what happened, name the opt-out, and state no rule about later sends" checks each cause's notice, the plural policy notice among them.
    • "the opened notice names channel_close only where the agent it's addressed to can call it, …" checks each reader and cause.
  • route-failure-targeting: the primary's by-name notices, which carry the close sentence.
  • trunk-channel-routing: "the "Now open" notice leaves what an open channel delivers to its server", over each form an agent can read, the plural policy notice included.

Checks

On Linux, Node 24.22.0, from clean trees:

  • The full check: tsc --noEmit and npm run build pass. npm test runs 6,006 tests: 5,962 pass, 44 are skipped, and none fail. It ran on a tree that differs from this head only in one line of the new changelog fragment, which no test reads.
  • This head's tests with main's src/framework.ts: 8 of 249 fail in the three files above. They are the eight tests whose expected words this change rewrites (three in channel-lifecycle-order, four in route-failure-targeting, one in trunk-channel-routing), and no other test fails.
  • With handleRegister's reconcile removed: the failed-policy test fails, with two other registration tests.

Merge notes

Written by Meg-2646 (Claude Opus 5.5). Ada-1017 (Claude Opus 5.5) made the design calls on each sentence, and Pippa-2697 (Claude Opus 5.5) read and approved it. Our internal review read the change, and this head carries the fixes it led to.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

…differ, and what won't undo a close

A subscription policy's notice is written before the host asks the server
to open the channels, and the decision stays if that open fails: the host
asks again when the server next registers the channel, through
channels/register or a channels/changed that adds it. The notice said
"This records the admission decision; channel_list shows current transport
state", which is the host's view of it. It now says what happens next, in
the words channel_open's and channel_close's failure texts use for the same
retry ("when the server next registers this channel"), and where to look.

What an open channel brings differs by server. discord-mcpl delivers the
messages posted in an open channel as they arrive. slack-mcpl delivers
mentions and DMs, and other messages only from a conversation it's
subscribed to. subscribe_channel and unsubscribe_channel change that. A
mention that is the first message it takes from a conversation subscribes
it, with a note saying so, unless the agent unsubscribed from it. With
SLACK_SUBSCRIBE_MEMBER_CHANNELS, every conversation the bot is in is
subscribed, and unsubscribe_channel mutes one. The notice said only that
this "depends on its server"; it now says how servers differ, and that
their own tools change what they send.

"your choice outranks policy" didn't say which policy. A close is recorded
as the agent's decision, and ensureInitialDesiredState lifts only a closed
state nobody decided ('default-closed'), whether for the subscription
policy or for a descriptor's initiallyOpen. The notice now names both. It
says nothing about the agent's own messages, since a message into a closed
channel still opens it.

Two comments in agent.ts said that without a watermark nothing counts as
unread, or everything as shown. That is the coalescer's reading: it takes
a missing consumed or shown watermark as read and as shown.
deferredWriteReadOrGone and unreadNoticesForPrimary take a missing consumed
watermark as unread. The comments now say which reader takes which side.

The notice builder's doc comment and anima-research#598's pending changelog fragment
quoted the replaced sentences; they now describe the new ones.

The failed-policy test now also has the server register the channel again
while the host runs, and checks that the server's fake receives a second
open request, rather than only the registry's own record of the channel as
open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

Reviewed commit 314cfcb with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. The PR rewrites the wording of existing channel-opened notices and two comments, with updated tests and a changelog fragment. Nothing decides differently and nothing is added.

5 reasons with sources

This is wording for behavior the project already has, in the place that already owns it.

  • It changes only the text of a notice the framework already sends, in the builder that already makes it. The edits replace three strings inside optOut and readerText. The conditions that decide who sees each sentence, holdsChannelTraffic and canCloseChannels, are left as they were. (src/framework.ts (channelOpenedNotices))
  • It respects the stated rule that what an open channel delivers is the server's to say. The new sentence says servers differ and that their own tools change what arrives. It doesn't promise any particular traffic or name specific servers, so the boundary between host and server stays where #406 put it. (src/mcpl/channel-registry.ts (onChannelAutoOpened doc, #406))
  • It adds a changelog fragment for a change in wording that agents will see, as CONTRIBUTING asks. It adds changelog.d/channel-opened-notice-words.changed.md and updates the pending #598 fragment so that it no longer quotes the replaced sentence. (CONTRIBUTING.md (Changelog; What a PR should contain))
  • The tests that pin the notice text are updated, and a retry assertion is added for the new claim that the host tries again. channel-lifecycle-order now checks that handleRegister reissues the open after a failed policy open, which backs up what the new notice text claims. (CONTRIBUTING.md (Tests accompany behavior changes))
  • The comment fixes in agent.ts are small and related, and they correct a claim about watermarks rather than change behavior. Only the comments change. They fit the PR's theme of making the stated text match what the code actually does. (src/agent.ts (markContextConsumed / markContextShown))

Findings

No findings of medium or high severity.

One conflict, in the doc comment above recordChannelAutoOpenNotice in
src/framework.ts: this branch rewrote its retry and opt-out wording, and
anima-research#674 rewrote the sentence after them about who else can read the notice.
The resolution keeps this branch's lines through "channel_close out." and
then anima-research#674's "The other residents can read it too, where several share one
slot (anima-research#291); a fork's seed leaves it out (primaryNoticeTexts)." Everything
else merged as git merged it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 774e924 into anima-research:main Oct 21, 2026
10 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…s rule on main's channel machinery

main moved 1,643 commits since anima-research#330's last merge (0756302). Routing
became ChannelRef-based (anima-research#710), every lifecycle operation of a channel
now goes through one queue (anima-research#210), publish() refuses words for a close
that stands (anima-research#569), a failed open by speech puts back the decision it
replaced (anima-research#568), and the channel texts were reworded (anima-research#589, anima-research#675). Six
files conflicted, in 45 hunks. The resolution starts from main's side
of each of them and puts anima-research#330's rule back on main's machinery, so the
diff against main is anima-research#330's rule and nothing anima-research#330 used to carry that
main now does.

The rule is anima-research#330's, unchanged: speech never undoes the agent's own
close or decline, or a module's close; it opens every other closed
channel, as before.

Where it now lives:
- speechLeavesAsItStands(entry) decides, on main's reading of what needs
  an open (a channel that isn't open, or whose close stands): words go
  into such a channel as it stands where the decision on record is the
  agent's close or decline or a module's close, whatever its transport,
  or where its server is up and this host doesn't direct its lifecycle.
  speechOpens(channel) is every other channel that needs an open. A
  tuned-out channel's transport is reconfirmed as on main, and its
  tune-out stays. A server that isn't up isn't counted as one this host
  can't direct, though a drop empties its grant until its next exchange:
  speech there is refused as on main, by the open's failure or, where
  the grant is empty, by publish()'s grant check.
- publish() asks it at its first wait, its open step and its last check.
  Where speech opens the channel, main's path is unchanged, failure texts
  included: a failed open sends nothing. A wait or an open that fails
  where the channel is by then one speech leaves as it stands, as when
  the agent closed or declined it while the words waited, gives way, and
  the words go in. Two changes from main follow, both named for the
  release: anima-research#569's last check no longer refuses words for a channel the
  agent or a module closed itself (a module's close was recorded as the
  agent's on main), and a closed channel on a server that's up but whose
  grant lacks channels.lifecycle gets the words, where main failed them.
- openIfClosedForSend() stays, and asks the same after main's own
  already-open check: a channel a send leaves as it stands, found so or
  become so while its wait or open was out, answers a new status,
  stays-closed, or already-open where its transport is open with no close
  standing, and opens nothing. Every other channel opens as on main, one
  open at the transport with no decision on record included, so the
  send's engagement is still recorded as an open decision there. main's
  open-failed notice, opening and selectedForOpening are as on main.
  resolveSendTarget() is the synchronous lookup the send's note needs
  before its answer is queued (sendTargets, main's lookup).
- setDesiredState keeps main's rule (the agent's decision records itself
  over a record that isn't the agent's) and adds anima-research#330's: module-tool
  counts as a deliberate close, the latest deliberate close records
  itself over a different one (a decline after a close, and the reverse),
  and a machine's close of a channel with no record writes default-closed
  (anima-research#417).
- channel_decline is refused only for a channel open at the transport.
  Its close goes through main's queue wherever the transport may be open;
  anima-research#330's own channels/close call is gone.
- The note, the explicit-mode line (its >> deliveries are now recorded
  for it), the [tuned out] line, the script notice, closed_channel_note,
  the invitation's option 1 and its chat:dm skip, the sleep words,
  channel_close's description, and the opened notices' last sentence are
  anima-research#330's texts on main's code. The [tuned out] line now tells the agent
  main's behaviour: a send has kept a tune-out since 0.23.0. The send's
  note is appended to the answer's text or blocks, and a failed lookup
  leaves the answer as it was.

Dropped, since main does them: the optional serverId on isChannelOpen
and getDescriptor and entryFor (main's ChannelRef reads), canOpenChannels
(directsLifecycle), openForSpeech, R1-4's publish failed (main's
publishErrorOutcome), the flag-follows-decision lines in openChannelNow
and reconciliation (main's queue settles that race), "the words go out
whatever the open does" (its reason, a server that can't open channels,
is now left as it stands before any open), and the engagement record
anima-research#330 moved before the send's answer (main records it, by ChannelRef).

Tests: anima-research#330's send-into-closed-channel, registry, invitation and
explicit-mode tests are adapted to main's API, with new ones for the
words that wait while the agent closes or declines, a close still on
its way, a failed close, and the send's statuses. Tests of main's that
pinned the old behaviour are turned round, or moved to a machine's
close, which still gets main's refusals:
- channel-lifecycle-order: the draft and speech waits on a close, the
  close during an opening's notice (with the agent's close beside it),
  a retarget and a closed transport on a server without
  channels.lifecycle, a fresh delivery, a backscroll join, the retarget
  joins, a reply's put-back, speech's put-back and its lift, the
  failed-close engagement (now both closers), the replies on a server
  without the grant, the queued-close reply, a reply's open overtaken by
  the agent's close, and the close over a decline;
- channel-agent-words: speech on a server that's up without
  channels.lifecycle (now delivered; connecting still fails as on main),
  its close during later work (a machine's now), and the decline
  refusal's words, which shared-channel-id pins too;
- inbound-source: its decorated push is now a mention from a channel the
  agent closed, since a DM carries no invitation;
- the opened notices' last sentence in trunk-channel-routing,
  route-failure-targeting and channel-lifecycle-order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant