From 63313148a475e89a2b0af7a043060953b2a277ae Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Tue, 13 Oct 2026 11:54:32 -0400 Subject: [PATCH 01/37] fix(mcpl): speaking never opens a channel A send tool's successful call (send_message, reply_message, send_dm, send_file, send_files) or plain speech into a registered channel that is closed now publishes there and leaves the channel closed, with its recorded decision unchanged. Opening is channel_open's, or subscription policy's and server bootstrap's, never a side effect of speaking. Before, routeSpeech opened a closed locus by delivery (openChannelNow(entry, 'opened-by-delivery')), and the framework's send path opened a closed target through openIfClosedForSend ('opened-by-reply'). Both recorded desired state 'open' over the agent's own channel_close, and the window got "[channels] Now open: ... your own message into the closed channel engaged it", whose closing promise ("neither policy nor a later send will silently reopen it") held only by its word "silently". The stated reason was that typing, reactions and inbound forwarding come alive with the reply. Typing and reactions don't read open state: the host's startTyping needs a registered channel and the channels.typing grant, and discord-mcpl's, telegram-mcpL's and eidoverse's typing handlers and discord-mcpl's reaction forwarding don't check it. The connectors publish to closed channels. - routeSpeech: the closed-locus open is gone, so speech into a closed channel publishes, and a failed open no longer withholds it. On a server without channels.lifecycle, such speech now publishes instead of failing on the open. - The send path: openIfClosedForSend is replaced by resolveSendTarget, a lookup that opens nothing. The send still records the channel as engaged this turn, by its descriptor id, now before the tool result is queued rather than after an open round trip. - onChannelAutoOpened reports only subscription-policy admissions, and its conversationId is gone. The policy notice's last sentence and channel_close's description say plainly that a closed channel stays closed until the agent opens it with channel_open. - The closed-channel invitation is not added to a direct message (chat:dm): all of a DM reaches the agent open or closed, and a DM the agent answers now stays closed, so the invitation would otherwise repeat on every DM. A whisper tagged chat:dm inside a closed shared channel carries none either. Tests: the routeSpeech closed-locus test now pins publishing without opening; a new framework-level test sends reply_message into a channel the agent closed; a DM push carries no invitation. The open-on-delivery failure test, the opened-by-delivery announcement test and the openIfClosedForSend test go with the code they tested. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 30 +++++ docs/explicit-prose-routing.md | 7 +- src/framework.ts | 119 +++++++---------- src/mcpl/channel-registry.ts | 127 ++++++------------- test/channel-registry-routing.test.ts | 96 ++++---------- test/send-into-closed-channel.test.ts | 91 +++++++++++++ test/trunk-channel-routing.test.ts | 35 +++++ 7 files changed, 264 insertions(+), 241 deletions(-) create mode 100644 changelog.d/speaking-never-opens.breaking.md create mode 100644 test/send-into-closed-channel.test.ts diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md new file mode 100644 index 000000000..1f66c266d --- /dev/null +++ b/changelog.d/speaking-never-opens.breaking.md @@ -0,0 +1,30 @@ +- **Residents and hosts:** speaking never opens a channel. A send tool's call (`send_message`, + `reply_message`, `send_dm`, `send_file`, `send_files`) or plain speech into a registered channel + that is closed publishes there and leaves the channel closed, with its recorded decision + unchanged. Before, it opened the channel, recorded that open over the agent's own + `channel_close`, and announced "[channels] Now open: … your own message into the closed channel + engaged it". + - **Who needs to act:** an agent that wants a channel's ongoing traffic after speaking there + opens it with `channel_open`. Apart from subscription policy and server bootstrap, that is + now the only way a closed channel opens. Messages that address the agent still arrive while + a channel is closed. + - **Unchanged:** typing indicators and reactions, which never depended on a channel being + open. Channels that earlier sends opened stay open; once the agent closes one, it stays + closed. Subscription-policy admission and its notice, whose last sentence now says that a + closed channel stays closed until the agent opens it, and that speaking there never reopens + it. `channel_close`'s description says the same. + - Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now + publishes; before, the open threw and the speech failed. A send no longer re-runs the open + of a channel decided open whose transport is down: reconciliation and `channel_open` do + that, as they do for a channel nobody speaks in. + - The closed-channel invitation is no longer added to a direct message (`chat:dm`): all of a + DM reaches the agent whether it is open or not, and a DM the agent answers now stays closed, + so the invitation would otherwise repeat on every DM. The tag describes the message, so a + whisper tagged `chat:dm` inside a shared channel that is closed carries none either. +- **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. + `resolveSendTarget(channelId, serverId?)` returns the descriptor id a send went to, from a + composite or raw id, and opens nothing. `onChannelAutoOpened` reports only + `source: 'subscription-policy'`, and its `conversationId` is gone. The + `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. +- **Prompt cache:** `channel_close`'s description changes, so the first request after the + upgrade that carries the channel tools misses the cache from its tool block on. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f78c6ff4..2d3e416ec 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -48,9 +48,10 @@ agent (then notices append without waking, breaking any loop). unprefixed prose bounces and prefixed prose is deliberate. - The entire "which event may move the agent's voice" question. -Explicit send tools (`send_message` etc.) are unchanged. Channel lifecycle -invariants (send-opens-channel, subscribed⇒open) are unchanged — a `>>` send -into a closed channel opens it, with the usual `[channels]` notice. +Explicit send tools (`send_message` etc.) are unchanged, and so is channel +lifecycle: speaking never opens a channel in any mode, so a `>>` send into a +closed channel publishes there and leaves it closed. Subscribed⇒open still +holds. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index 9877d24c3..b6afeebd4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -235,9 +235,10 @@ function estimateAppendedRoundTokens( } /** - * Explicit delivery tools whose successful use into a closed channel OPENS - * it (send implies engagement — see openIfClosedForSend). Bare tool names, - * matched after stripping the MCPL server prefix. + * Explicit delivery tools. A successful call records the channel it + * delivered into as engaged this turn (turnEngagedChannels) and opens + * nothing: speaking never opens a channel. Bare tool names, matched after + * stripping the MCPL server prefix. */ const SEND_ENGAGEMENT_TOOLS = new Set([ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files', @@ -9462,6 +9463,14 @@ export class AgentFramework { * the surface tracks a missed-ambient tally (discord-mcpl sends * `missedMessages`/`missedCharacters` in push origin), the invitation shows * the count — the cost of staying out, as a number, at decision time. + * + * No invitation on a direct message (`chat:dm`): every message of a DM is + * addressed, so it reaches the agent open or closed, and there is nothing + * to miss or join. Speaking never opens a channel, so a DM the agent has + * answered stays closed, and without this every DM would carry the block. + * The tag describes the message, not the channel: a whisper tagged + * `chat:dm` inside a shared channel that is closed carries no invitation + * either, though its channel's other traffic stays out of reach. */ private buildClosedChannelInvitation(opts: { serverId: string; @@ -9478,6 +9487,7 @@ export class AgentFramework { }): { block: ContentBlock; metadataPatch: Record } | null { if (!this.channelRegistry) return null; if (!opts.tags?.includes('chat:addressed')) return null; + if (opts.tags.includes('chat:dm')) return null; if (this.channelRegistry.isChannelOpen(opts.channelId)) return null; const descriptor = this.channelRegistry.getDescriptor(opts.channelId); const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0; @@ -10593,40 +10603,32 @@ export class AgentFramework { } /** - * Durable window notice for a channel the agent did not ask to open — - * subscription-policy admission, or an open forced by the agent's own - * delivery into a closed channel. The agent must always learn that new + * Durable window notice for channels the agent did not ask to open: the + * subscription policy admitted them. (Speaking never opens a channel, so + * nothing the agent says lands here.) The agent must always learn that new * traffic will start flowing, and that `channel_close` opts out (their * decision outranks policy). System message: never triggers inference, * never conversational, never locus-eligible. */ - private recordChannelAutoOpenNotice( - agentName: string | undefined, - channels: Array<{ channelId: string; label?: string }>, - cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', - ): void { + private recordChannelAutoOpenNotice(channels: Array<{ channelId: string; label?: string }>): void { if (channels.length === 0) return; const shown = channels .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) .join(', '); const plural = channels.length > 1; - const causeText = - cause === 'subscription-policy' - ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it') - : 'your own message into the closed channel engaged it'; const text = - `[channels] Now open: ${shown} — ${causeText}. Ongoing traffic from ` + - (plural ? 'these channels' : 'this channel') + - ` will reach you. If you don't want ${plural ? 'one of them' : 'it'}, use channel_close — ` + - 'your choice sticks; neither policy nor a later send will silently reopen it.'; + `[channels] Now open: ${shown} — your subscription policy just admitted ${plural ? 'them' : 'it'}. ` + + `Ongoing traffic from ${plural ? 'these channels' : 'this channel'} will reach you. ` + + `If you don't want ${plural ? 'one of them' : 'it'}, use channel_close: a closed channel stays ` + + 'closed until you open it with channel_open, and speaking there never reopens it.'; try { // Through framework.addMessage for mid-turn safety (deferral + - // hear-while-acting injection) — a delivery-forced open happens inside - // a live turn, and a direct context-manager append there would store - // the notice out of order. Targets the primary agent; connectome-host - // runs trunk-only, so this is the delivering agent in practice. + // hear-while-acting injection): an admission can arrive inside a live + // turn (a channels/changed mid-stream), and a direct context-manager + // append there would store the notice out of order. Targets the + // primary agent. this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' }); - console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`); + console.error(`[channel] auto-open notice (subscription-policy): ${shown}`); } catch (err) { console.error('recordChannelAutoOpenNotice failed:', err); } @@ -15743,12 +15745,10 @@ export class AgentFramework { console.error('onRouteFailure: failed to record send-failure marker:', err); } }, - // A channel opened without the agent asking (policy admission or an - // open forced by delivery) must be announced in the window, with the - // opt-out named. Routed here so registry-driven opens and framework- - // driven opens produce the same durable notice. - onChannelAutoOpened: ({ conversationId, source, channels }) => { - this.recordChannelAutoOpenNotice(conversationId, channels, source); + // A channel the subscription policy opened without the agent asking + // must be announced in the window, with the opt-out named. + onChannelAutoOpened: ({ channels }) => { + this.recordChannelAutoOpenNotice(channels); }, }, ); @@ -17639,13 +17639,13 @@ export class AgentFramework { } } - // INVARIANT: engaging a channel opens it. A successful explicit send - // into a channel the registry knows as closed drives the same open - // flow as channel_open (durable desired state, server subscribe), so - // there is no such thing as posting into a channel that stays - // half-alive (no typing, no reactions, no inbound forwarding). - // Fire-and-forget: the send already succeeded; the open must not - // delay or fail the tool result. + // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31 n=7): a + // human follow-up in a channel the agent explicitly sent into this + // turn counts as conversationally addressed. Injection metadata + // carries the CANONICAL channel id (descriptor form), while send args + // often carry the raw provider id, so the registry resolves it first. + // The send opens nothing: speaking never opens a channel, so a closed + // channel stays closed, with its desired state as the agent left it. if ( this.channelRegistry && SEND_ENGAGEMENT_TOOLS.has(toolName) && @@ -17654,43 +17654,14 @@ export class AgentFramework { const target = extractChannelIdFromToolResult(result.content) ?? (typeof args.channelId === 'string' ? (args.channelId as string) : undefined); - if (target) { - this.channelRegistry - .openIfClosedForSend(target, serverId) - .then(({ status, channelId, label }) => { - // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31 - // n=7): a human follow-up in a channel the agent explicitly - // sent into this turn counts as conversationally addressed. - // Recorded here — not eagerly — because injection metadata - // carries the CANONICAL channel id (descriptor form), while - // send args often carry the raw provider id; this callback - // has the resolved form. The already-open path resolves on - // the microtask queue, i.e. before the tool-result event - // that evaluates injections. - if (status !== 'unknown-channel' && status !== 'ambiguous') { - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); - } - engaged.add(channelId ?? target); - } - if (status === 'opened') { - console.error( - `[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`, - ); - this.recordChannelAutoOpenNotice( - agentName, - [{ channelId: channelId ?? target, label }], - 'opened-by-reply', - ); - } else if (status === 'open-failed') { - console.error( - `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`, - ); - } - }) - .catch((err) => console.error('[channel] open-on-send error:', err)); + const engagedChannel = target ? this.channelRegistry.resolveSendTarget(target, serverId) : undefined; + if (engagedChannel) { + let engaged = this.turnEngagedChannels.get(agentName); + if (!engaged) { + engaged = new Set(); + this.turnEngagedChannels.set(agentName, engaged); + } + engaged.add(engagedChannel); } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 41da16ead..4128106fd 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -378,7 +378,10 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_close', - description: 'Close a channel to stop receiving messages', + description: + 'Close a channel to stop receiving its ongoing traffic. Messages that address you still ' + + 'reach you, and you can still speak there. It stays closed until you open it with ' + + 'channel_open: speaking there never reopens it.', inputSchema: { type: 'object' as const, properties: { @@ -501,19 +504,18 @@ interface ChannelRegistryOptions { textLen: number; }) => void; /** - * Called when channels were opened WITHOUT the agent asking (subscription - * policy admitting a newly discovered channel, or delivery into a closed - * locus). The host wires this to drop a durable notice into the agent's - * window — the agent must always learn that new traffic will start - * flowing, and that `channel_close` opts out (their decision outranks - * policy). Fires ONCE per channel ever: the desired-state decision is - * durable, so reboots do not re-announce. Must not trigger inference. + * Called when the subscription policy opened channels WITHOUT the agent + * asking (admitting newly discovered channels). The host wires this to drop + * a durable notice into the agent's window — the agent must always learn + * that new traffic will start flowing, and that `channel_close` opts out + * (their decision outranks policy). Fires ONCE per channel ever: the + * desired-state decision is durable, so reboots do not re-announce. Must + * not trigger inference. Speaking never opens a channel, so no send or + * delivery is reported here. */ onChannelAutoOpened?: (info: { - /** Agent whose action caused the open (delivery); absent for policy opens. */ - conversationId?: string; serverId: string; - source: 'subscription-policy' | 'opened-by-delivery'; + source: 'subscription-policy'; channels: Array<{ channelId: string; label?: string }>; }) => void; /** @@ -581,9 +583,8 @@ export class ChannelRegistry { textLen: number; }) => void; private onChannelAutoOpened?: (info: { - conversationId?: string; serverId: string; - source: 'subscription-policy' | 'opened-by-delivery'; + source: 'subscription-policy'; channels: Array<{ channelId: string; label?: string }>; }) => void; private homeChannelResolver?: (agentName: string) => string | undefined; @@ -2383,13 +2384,13 @@ export class ChannelRegistry { } /** - * The single open executor: record durable desired-open intent, tell the - * server to subscribe, mark the live entry open. Every path that opens a - * channel — the agent's channel_open tool, delivery into a closed locus, - * an explicit send into a closed channel — funnels here, so lifecycle - * events, desired state, and live state can never diverge by path. - * Desired state is recorded BEFORE the server round-trip: intent sticks - * even if the subscribe fails, and reconciliation retries later. + * The open executor behind the agent's channel_open tool: record durable + * desired-open intent, tell the server to subscribe, mark the live entry + * open. Speaking never opens a channel, so no send or delivery comes here: + * an agent's speech into a closed channel publishes and leaves the + * channel's lifecycle as the agent left it. Desired state is recorded + * BEFORE the server round-trip: intent sticks even if the subscribe fails, + * and reconciliation retries later. */ private async openChannelNow( entry: ChannelEntry, @@ -2597,21 +2598,17 @@ export class ChannelRegistry { } /** - * Open a channel because something was DELIVERED into it (explicit send - * tool or routed speech). Sending into a closed channel is not a thing: - * engaging a channel opens it, so typing indicators, reaction machinery, - * and inbound forwarding all come alive with the first outbound message. - * Accepts composite MCPL ids or raw server-internal ids (explicit send - * tools receive whatever the agent typed). + * The registered channel an explicit send tool delivered into, as its + * descriptor id, from the id the tool reported or was given: a composite + * MCPL id, or the raw server-internal id (`address.channelId`), since send + * tools receive whatever the agent typed. Undefined when no channel on + * `serverId` matches, or more than one does. A lookup only: speaking never + * opens a channel, so a send into a closed channel leaves it closed, with + * its desired state as the agent left it. Typing indicators and reactions + * don't depend on a channel being open; opening only subscribes to its + * ongoing traffic. */ - async openIfClosedForSend( - rawChannelId: string, - serverId?: string, - ): Promise<{ - status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed'; - channelId?: string; - label?: string; - }> { + resolveSendTarget(rawChannelId: string, serverId?: string): string | undefined { let matches = [...this.channels.values()].filter( (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId), ); @@ -2622,30 +2619,7 @@ export class ChannelRegistry { (!serverId || e.serverId === serverId), ); } - if (matches.length === 0) return { status: 'unknown-channel' }; - if (matches.length > 1) return { status: 'ambiguous' }; - const entry = matches[0]!; - const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label }; - if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') { - return { status: 'already-open', ...resolved }; - } - try { - await this.openChannelNow(entry, 'opened-by-reply'); - this.emitTraceFn({ - type: 'mcpl:channel-opened-by-send', - serverId: entry.serverId, - channelId: entry.descriptor.id, - }); - return { status: 'opened', ...resolved }; - } catch (err) { - this.emitTraceFn({ - type: 'mcpl:channel-open-failed', - serverId: entry.serverId, - channelId: entry.descriptor.id, - error: (err as Error).message, - }); - return { status: 'open-failed', ...resolved }; - } + return matches.length === 1 ? matches[0]!.descriptor.id : undefined; } private async handleToolOpen(input: { @@ -3100,38 +3074,11 @@ export class ChannelRegistry { return fail(channelId, `no registered channel for locus "${channelId}"`); } - // INVARIANT: it is not possible to send into a closed channel. Speech - // routed into a closed-but-registered locus (the canonical case: a DM - // that woke the agent — DMs register closed) OPENS the channel first, - // so typing indicators, reaction machinery, and inbound forwarding come - // alive with the reply. If the open fails, the speech does NOT go out: - // a half-alive delivery is worse than a loud marker. - if (!entry.open) { - try { - await this.openChannelNow(entry, 'opened-by-delivery'); - console.error( - `[routeSpeech] ${conversationId}: locus ${logValue(channelId)} was closed — opened by delivery (speech implies engagement)`, - ); - this.emitTraceFn({ - type: 'mcpl:channel-opened-by-send', - serverId: entry.serverId, - channelId, - }); - try { - this.onChannelAutoOpened?.({ - conversationId, - serverId: entry.serverId, - source: 'opened-by-delivery', - channels: [{ channelId, label: entry.descriptor.label }], - }); - } catch (err) { - console.error('onChannelAutoOpened (delivery) failed:', err); - } - } catch (err) { - return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`); - } - } - + // Speaking never opens a channel: speech into a closed but registered + // locus (a DM that woke the agent, since DMs register closed) publishes + // and leaves the channel's lifecycle as the agent left it. Publishing + // doesn't need an open channel, and neither do typing and reactions; + // opening only subscribes to ongoing traffic, which is channel_open's. const server = this.serverRegistry.getServer(entry.serverId); if (!server) { return fail(channelId, `server "${entry.serverId}" not found`); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80a825658..fccd8d262 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -17,9 +17,8 @@ function makeRegistry( homeChannelResolver?: (agentName: string) => string | undefined, activeChannelResolver?: (agentName: string) => string | undefined, onChannelAutoOpened?: (info: { - conversationId?: string; serverId: string; - source: 'subscription-policy' | 'opened-by-delivery'; + source: 'subscription-policy'; channels: Array<{ channelId: string; label?: string }>; }) => void, ) { @@ -29,7 +28,6 @@ function makeRegistry( const openCalls: Array<{ channelId?: string }> = []; const closeCalls: Array<{ channelId?: string }> = []; - let failOpens = false; const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. @@ -39,7 +37,6 @@ function makeRegistry( return publishResult; }, sendChannelsOpen: async (params: { channelId?: string }) => { - if (failOpens) throw new Error('open refused by server'); openCalls.push(params); return {}; }, @@ -72,10 +69,7 @@ function makeRegistry( findChannelEntry(id: string): { serverId: string; open: boolean; descriptor: { id: string; label: string; metadata?: Record } } | undefined; }).findChannelEntry(channelId); - return { - registry, failures, traces, publishCalls, openCalls, closeCalls, lookup, - setFailOpens: (v: boolean) => { failOpens = v; }, - }; + return { registry, failures, traces, publishCalls, openCalls, closeCalls, lookup }; } function incoming(channelId: string, text: string, channelName?: string) { @@ -316,44 +310,30 @@ test('ensureChannelRegistered is idempotent and does not reopen a closed channel }); // --------------------------------------------------------------------------- -// Channel-lifecycle invariants (2026-07-22): sending into a closed channel is -// not a thing — delivery opens it; subscribed ⇒ open at any discovery time. +// Channel-lifecycle invariants: speaking never opens a channel; subscribed ⇒ +// open at any discovery time. // --------------------------------------------------------------------------- -test('routeSpeech into a closed locus opens the channel first, then delivers', async () => { - const { registry, publishCalls, openCalls, lookup } = makeRegistry({ delivered: true }); - - // A DM-shaped situation: the channel is registered but closed. - seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); - lookup('dm-alice')!.open = false; - - const res = await registry.routeSpeech('sol', 'a reply meant for the DM', 'dm-alice'); - - assert.deepEqual(res, { delivered: true, channelId: 'dm-alice' }); - assert.equal(openCalls.length, 1, 'delivery into a closed channel must open it'); - assert.equal(openCalls[0]!.channelId, 'dm-alice'); - assert.equal(lookup('dm-alice')!.open, true, 'live state flips open'); - assert.equal(registry.getDesiredState('discord', 'dm-alice'), 'open', - 'durable desired state records the engagement'); - assert.equal(publishCalls.at(-1)?.channelId, 'dm-alice'); -}); - -test('routeSpeech does NOT deliver when the open-on-delivery fails', async () => { - const { registry, failures, publishCalls, lookup, setFailOpens } = makeRegistry({ delivered: true }); +test('routeSpeech into a closed channel publishes and leaves it closed (speaking never opens)', async () => { + const autoOpened: unknown[] = []; + const { registry, publishCalls, openCalls, lookup } = makeRegistry( + { delivered: true }, undefined, undefined, (info) => autoOpened.push(info), + ); - seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); - lookup('dm-alice')!.open = false; - const publishesBefore = publishCalls.length; - setFailOpens(true); + // A channel the agent closed: registered, transport closed, decision recorded. + seedRegistered(registry, 'discord', 'upkeep'); + (registry as unknown as { + setDesiredState(s: string, c: string, d: 'open' | 'closed', src: string): void; + }).setDesiredState('discord', 'upkeep', 'closed', 'agent-tool'); - const res = await registry.routeSpeech('sol', 'must not go out', 'dm-alice'); + const res = await registry.routeSpeech('sol', 'a reply into a channel she closed', 'upkeep'); - assert.equal(res, null, 'no delivery into a channel that could not be opened'); - assert.equal(publishCalls.length, publishesBefore, 'publish must not be attempted'); - assert.equal(failures.length, 1); - assert.match(failures[0]!.reason, /closed and open failed/); + assert.deepEqual(res, { delivered: true, channelId: 'upkeep' }); + assert.equal(publishCalls.at(-1)?.channelId, 'upkeep', 'the speech is published'); + assert.equal(openCalls.length, 0, 'speaking never opens a channel'); + assert.equal(lookup('upkeep')!.open, false, 'the transport stays closed'); + assert.equal(registry.getDesiredState('discord', 'upkeep'), 'closed', "the agent's close stands"); + assert.equal(autoOpened.length, 0, 'nothing is announced as opened'); }); test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { @@ -423,24 +403,8 @@ test('an agent decision to stay closed sticks — policy does not override chann assert.ok(!openCalls.some(c => c.channelId === 'declined-chan')); }); -test('openIfClosedForSend resolves raw server-internal ids and opens the channel', async () => { - const { registry, openCalls, lookup } = makeRegistry({ delivered: true }); - - await registry.handleChanged('discord', { - added: [{ id: 'discord:dm:444555', type: 'discord', label: 'DM: Alice', - direction: 'bidirectional', address: { guildId: 'dm', channelId: '444555' } }], - } as never); - assert.equal(lookup('discord:dm:444555')!.open, false, 'DM registers closed'); - - const { status } = await registry.openIfClosedForSend('444555', 'discord'); - - assert.equal(status, 'opened'); - assert.equal(lookup('discord:dm:444555')!.open, true); - assert.ok(openCalls.some(c => c.channelId === 'discord:dm:444555')); -}); - test('policy admissions are announced to the agent exactly once, as one batch', async () => { - const autoOpened: Array<{ conversationId?: string; source: string; channels: Array<{ channelId: string }> }> = []; + const autoOpened: Array<{ source: string; channels: Array<{ channelId: string }> }> = []; const { registry } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); registry.setSubscriptionPolicy('discord', ['chan-1', 'chan-2']); @@ -462,22 +426,6 @@ test('policy admissions are announced to the agent exactly once, as one batch', assert.equal(autoOpened.length, 1, 'a durable decision is never re-announced'); }); -test('opened-by-delivery is announced to the delivering agent', async () => { - const autoOpened: Array<{ conversationId?: string; source: string; channels: Array<{ channelId: string }> }> = []; - const { registry, lookup } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); - - seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); - lookup('dm-alice')!.open = false; - - await registry.routeSpeech('sol', 'reply', 'dm-alice'); - - assert.equal(autoOpened.length, 1); - assert.equal(autoOpened[0]!.source, 'opened-by-delivery'); - assert.equal(autoOpened[0]!.conversationId, 'sol'); - assert.deepEqual(autoOpened[0]!.channels.map(c => c.channelId), ['dm-alice']); -}); - test('resolveProseTarget matches the name segment of suffixed labels (#fable vs "#fable (guild)")', async () => { const { registry } = makeRegistry({ delivered: true }); await registry.handleChanged('discord', { diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts new file mode 100644 index 000000000..7775323e3 --- /dev/null +++ b/test/send-into-closed-channel.test.ts @@ -0,0 +1,91 @@ +/** + * Speaking never opens a channel: an explicit send tool's successful call + * into a channel the agent closed posts there (the server sends it) and + * leaves the channel's lifecycle as the agent left it, with no "[channels] + * Now open" notice. The call still records the channel as engaged this turn, + * by its descriptor id, for the mid-turn re-pin. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { AgentFramework } from '../src/framework.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; + +test('a reply_message into a channel the agent closed leaves it closed, and records the turn engagement', async () => { + const opens: Array<{ channelId?: string }> = []; + const server = { + id: 'discord', + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + async sendToolsCall() { + // discord-mcpl's send tools answer with the posted message and the raw channel id. + return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: '444555' }) }] }; + }, + async sendChannelsOpen(params: { channelId?: string }) { + opens.push(params); + return {}; + }, + async sendChannelsClose() { + return {}; + }, + }; + const serverRegistry = { + getServer: (id: string) => (id === 'discord' ? server : undefined), + } as unknown as McplServerRegistry; + const registry = new ChannelRegistry(serverRegistry, {} as FeatureSetManager, () => {}, () => {}); + + const channelId = 'discord:g1:444555'; + await registry.handleChanged('discord', { + added: [{ id: channelId, type: 'discord', label: '#upkeep', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '444555' } }], + } as never); + // The agent had the channel open, then closed it. + const origin = { kind: 'agent' as const, agentName: 'sol' }; + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + const opensBefore = opens.length; + + // A framework with just what dispatchMcplToolCall reads. + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerRegistry = serverRegistry; + framework.mcplServerConfigs = new Map([['discord', { id: 'discord', toolPrefix: 'discord' }]]); + framework.mcplToolFeatureSets = new Map(); + framework.checkpointManager = null; + framework.channelRegistry = registry; + framework.turnEngagedChannels = new Map(); + framework.emitTrace = () => {}; + const windowMessages: string[] = []; + framework.addMessage = (_role: string, content: Array<{ text?: string }>) => { + windowMessages.push(content.map((b) => b.text ?? '').join('')); + return 'msg'; + }; + + const result = await new Promise<{ success: boolean }>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); + framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean } }) => { + if (event.type === 'tool-result' && event.callId === 'call-reply') { + clearTimeout(timeout); + resolve(event.result); + } + }; + framework.dispatchMcplToolCall( + 'sol', + { id: 'call-reply', name: 'discord--reply_message', input: { channelId: '444555', messageId: 'm1', content: 'answering, without moving in' } }, + 'discord', + 'discord', + ); + }); + // Let anything the send started on its own settle before looking. + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(result.success, true); + assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); + assert.equal(registry.isChannelOpen(channelId), false, 'the channel stays closed'); + assert.equal(registry.getDesiredState('discord', channelId), 'closed', "the agent's close stands"); + assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); + assert.deepEqual([...(framework.turnEngagedChannels.get('sol') ?? [])], [channelId], + 'the raw id resolves to the descriptor id the re-pin compares'); +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..20ae70d3c 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -305,6 +305,41 @@ describe('Trunk channel routing (item-3 redux)', () => { await fw3.stop(); }); + it('a direct message carries no invitation: all of a DM reaches the agent, open or closed', async () => { + // Speaking never opens a channel, so a DM the agent has answered stays closed. An invitation + // on each DM would repeat a block whose premise is false there: there is no other traffic + // to miss, and nothing to join. + const framework = await makeFramework(); + const i = internals(framework); + i.channelRegistry = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), + proseTargetFor: () => '@_reim0n', + stopAll: () => {}, + }; + i.handleMcplPushEvent({ + type: 'mcpl:push-event', + serverId: 'discord', + featureSet: 'discord.messaging', + eventId: 'discord_msg_m6', + content: [{ type: 'text', text: '_reim0n: you there?' }], + origin: { source: 'discord', messageId: 'm6', mcplChannelId: 'discord:dm:1555', isDM: true, authorName: '_reim0n' }, + // As discord-mcpl tags a DM, after the host's core-tag closure. + tags: ['chat:dm', 'chat:addressed', 'chat:private'], + timestamp: new Date().toISOString(), + inferenceId: 'i6', + triggerInference: false, + }); + const text = framework.getAgent('scout')!.getContextManager().queryMessages({}).messages.at(-1)?.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('\n') ?? ''; + assert.match(text, /you there\?/); + assert.doesNotMatch(text, /Channel invitation/); + await framework.stop(); + }); + it('a channel-incoming trunk turn records its triggering channel', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); From 1e2d279e33a0ea977214c836f03324c3f6bc8c88 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Tue, 13 Oct 2026 22:04:50 -0400 Subject: [PATCH 02/37] fix(mcpl): a send leaves a tune-out in place; the invitation and docs follow the rule Answers an internal review of the previous commit. - A send tool's call into a tuned-out channel no longer ends the tune-out. On main it fell through openIfClosedForSend's already-open check, which wanted desired 'open', and openChannelNow set the decision to open. The previous commit removed that path; a test now pins it, and a fixed fragment names it, beside speech on a server without channels.lifecycle now publishing. - The closed-channel invitation's fallback, for a channel no single-word target reaches, no longer says that replying needs a join: it points to the server's own send tool, or a join. - The policy notice and channel_close's description speak of the channel the agent closes: it stays closed until channel_open, and neither speaking there nor policy reopens it. - resolveSendTarget's parameter is channelId, since it takes a composite or raw id, and its doc says it matches across servers when no serverId is given. - docs/explicit-prose-routing.md no longer says send tools and lifecycle are "unchanged" next to a claim about subscribed channels. - The breaking fragment says what else changes, and that a DM known only from push events, which no reconcile reaches, now stays closed after a restart until the agent opens it. Tests: the routeSpeech test also covers a channel decided open whose transport flag is false; a direct test pins resolveSendTarget's lookup and its refusal to guess; the send test reads the engagement when the tool result is queued, and a second case sends into a tuned-out channel. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 35 +++---- changelog.d/speaking-never-opens.fixed.md | 4 + docs/explicit-prose-routing.md | 7 +- src/framework.ts | 8 +- src/mcpl/channel-registry.ts | 13 +-- test/channel-registry-routing.test.ts | 45 ++++++-- test/send-into-closed-channel.test.ts | 102 +++++++++++++------ test/trunk-channel-routing.test.ts | 4 +- 8 files changed, 143 insertions(+), 75 deletions(-) create mode 100644 changelog.d/speaking-never-opens.fixed.md diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 1f66c266d..6740b2aa7 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -5,25 +5,26 @@ `channel_close`, and announced "[channels] Now open: … your own message into the closed channel engaged it". - **Who needs to act:** an agent that wants a channel's ongoing traffic after speaking there - opens it with `channel_open`. Apart from subscription policy and server bootstrap, that is - now the only way a closed channel opens. Messages that address the agent still arrive while - a channel is closed. + opens it with `channel_open`; speaking no longer does. Messages that address the agent still + arrive while a channel is closed. + - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s + description now say that a channel the agent closes stays closed until it opens it with + `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel + invitation is no longer added to a direct message (`chat:dm`), all of which reaches the + agent whether the DM is open or not; a DM the agent answers now stays closed, so the + invitation would otherwise repeat on every DM. A whisper tagged `chat:dm` inside a shared + channel that is closed carries none either. Where no single-word target reaches a channel, + the invitation no longer says that replying needs a join. A send no longer re-runs the open + of a channel decided open whose transport is down: `channel_open` and the next registration's + reconciliation do that, as they do for a channel nobody speaks in. - **Unchanged:** typing indicators and reactions, which never depended on a channel being - open. Channels that earlier sends opened stay open; once the agent closes one, it stays - closed. Subscription-policy admission and its notice, whose last sentence now says that a - closed channel stays closed until the agent opens it, and that speaking there never reopens - it. `channel_close`'s description says the same. - - Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now - publishes; before, the open threw and the speech failed. A send no longer re-runs the open - of a channel decided open whose transport is down: reconciliation and `channel_open` do - that, as they do for a channel nobody speaks in. - - The closed-channel invitation is no longer added to a direct message (`chat:dm`): all of a - DM reaches the agent whether it is open or not, and a DM the agent answers now stays closed, - so the invitation would otherwise repeat on every DM. The tag describes the message, so a - whisper tagged `chat:dm` inside a shared channel that is closed carries none either. + open. A registered channel with a stored decision to open, including one that an earlier + send opened, reconciles open at its next registration, as before. A DM known only from push + events is never reconciled: one that an earlier send opened used to reopen on the agent's + next reply after a restart, and now stays closed until the agent opens it. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. - `resolveSendTarget(channelId, serverId?)` returns the descriptor id a send went to, from a - composite or raw id, and opens nothing. `onChannelAutoOpened` reports only + `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went + to, from a composite or raw id, and opens nothing. `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. - **Prompt cache:** `channel_close`'s description changes, so the first request after the diff --git a/changelog.d/speaking-never-opens.fixed.md b/changelog.d/speaking-never-opens.fixed.md new file mode 100644 index 000000000..18a802bd8 --- /dev/null +++ b/changelog.d/speaking-never-opens.fixed.md @@ -0,0 +1,4 @@ +- A send tool's call into a tuned-out channel no longer ends the tune-out. It used to set the + channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". +- Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now publishes. + Before, the open that preceded publishing threw, and the speech failed. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 2d3e416ec..c160d58c2 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -48,10 +48,9 @@ agent (then notices append without waking, breaking any loop). unprefixed prose bounces and prefixed prose is deliberate. - The entire "which event may move the agent's voice" question. -Explicit send tools (`send_message` etc.) are unchanged, and so is channel -lifecycle: speaking never opens a channel in any mode, so a `>>` send into a -closed channel publishes there and leaves it closed. Subscribed⇒open still -holds. +Explicit mode leaves the send tools (`send_message` etc.) and channel +lifecycle as they are in every mode. Speaking never opens a channel, so a +`>>` send into a closed channel publishes there and leaves it closed. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index b6afeebd4..e6f93fb56 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9506,7 +9506,7 @@ export class AgentFramework { const replyOption = target ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` - : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`; + : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; use the server's own send tool, or join it (option 2) and reply.\n`; const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -10619,8 +10619,8 @@ export class AgentFramework { const text = `[channels] Now open: ${shown} — your subscription policy just admitted ${plural ? 'them' : 'it'}. ` + `Ongoing traffic from ${plural ? 'these channels' : 'this channel'} will reach you. ` + - `If you don't want ${plural ? 'one of them' : 'it'}, use channel_close: a closed channel stays ` + - 'closed until you open it with channel_open, and speaking there never reopens it.'; + `If you don't want ${plural ? 'one of them' : 'it'}, use channel_close: a channel you close ` + + 'stays closed until you open it with channel_open, and neither speaking there nor policy reopens it.'; try { // Through framework.addMessage for mid-turn safety (deferral + // hear-while-acting injection): an admission can arrive inside a live @@ -17646,6 +17646,8 @@ export class AgentFramework { // often carry the raw provider id, so the registry resolves it first. // The send opens nothing: speaking never opens a channel, so a closed // channel stays closed, with its desired state as the agent left it. + // A closed channel delivers only follow-ups that address the agent, + // which re-pin on their own. if ( this.channelRegistry && SEND_ENGAGEMENT_TOOLS.has(toolName) && diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4128106fd..6c74c7307 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -381,7 +381,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ description: 'Close a channel to stop receiving its ongoing traffic. Messages that address you still ' + 'reach you, and you can still speak there. It stays closed until you open it with ' + - 'channel_open: speaking there never reopens it.', + 'channel_open: neither speaking there nor subscription policy reopens it.', inputSchema: { type: 'object' as const, properties: { @@ -2601,21 +2601,22 @@ export class ChannelRegistry { * The registered channel an explicit send tool delivered into, as its * descriptor id, from the id the tool reported or was given: a composite * MCPL id, or the raw server-internal id (`address.channelId`), since send - * tools receive whatever the agent typed. Undefined when no channel on - * `serverId` matches, or more than one does. A lookup only: speaking never + * tools receive whatever the agent typed. Matches on `serverId` when it is + * given, across every server otherwise, and is undefined when nothing + * matches or more than one channel does. A lookup only: speaking never * opens a channel, so a send into a closed channel leaves it closed, with * its desired state as the agent left it. Typing indicators and reactions * don't depend on a channel being open; opening only subscribes to its * ongoing traffic. */ - resolveSendTarget(rawChannelId: string, serverId?: string): string | undefined { + resolveSendTarget(channelId: string, serverId?: string): string | undefined { let matches = [...this.channels.values()].filter( - (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId), + (e) => e.descriptor.id === channelId && (!serverId || e.serverId === serverId), ); if (matches.length === 0) { matches = [...this.channels.values()].filter( (e) => - (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId && + (e.descriptor.address as { channelId?: string } | undefined)?.channelId === channelId && (!serverId || e.serverId === serverId), ); } diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index fccd8d262..bb60a8ee5 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -319,23 +319,46 @@ test('routeSpeech into a closed channel publishes and leaves it closed (speaking const { registry, publishCalls, openCalls, lookup } = makeRegistry( { delivered: true }, undefined, undefined, (info) => autoOpened.push(info), ); - - // A channel the agent closed: registered, transport closed, decision recorded. - seedRegistered(registry, 'discord', 'upkeep'); - (registry as unknown as { + const setDesired = (registry as unknown as { setDesiredState(s: string, c: string, d: 'open' | 'closed', src: string): void; - }).setDesiredState('discord', 'upkeep', 'closed', 'agent-tool'); - - const res = await registry.routeSpeech('sol', 'a reply into a channel she closed', 'upkeep'); - - assert.deepEqual(res, { delivered: true, channelId: 'upkeep' }); - assert.equal(publishCalls.at(-1)?.channelId, 'upkeep', 'the speech is published'); + }).setDesiredState.bind(registry); + + // A channel the agent closed, and one it opened whose transport is down (a + // failed open): both registered with the transport flag false. + seedRegistered(registry, 'discord', 'upkeep', 'standup'); + setDesired('discord', 'upkeep', 'closed', 'agent-tool'); + setDesired('discord', 'standup', 'open', 'agent-tool'); + + for (const channelId of ['upkeep', 'standup']) { + const res = await registry.routeSpeech('sol', `a reply into ${channelId}`, channelId); + assert.deepEqual(res, { delivered: true, channelId }); + assert.equal(publishCalls.at(-1)?.channelId, channelId, 'the speech is published'); + assert.equal(lookup(channelId)!.open, false, 'the transport flag is left as it was'); + } assert.equal(openCalls.length, 0, 'speaking never opens a channel'); - assert.equal(lookup('upkeep')!.open, false, 'the transport stays closed'); assert.equal(registry.getDesiredState('discord', 'upkeep'), 'closed', "the agent's close stands"); + assert.equal(registry.getDesiredState('discord', 'standup'), 'open'); assert.equal(autoOpened.length, 0, 'nothing is announced as opened'); }); +test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + const added = (id: string, raw: string) => ({ + id, type: 'discord', label: `#${raw}`, direction: 'bidirectional', address: { guildId: 'g', channelId: raw }, + }); + await registry.handleChanged('discord-a', { added: [added('discord:g:444', '444'), added('discord:g:555', '555')] } as never); + await registry.handleChanged('discord-b', { added: [added('discord:h:555', '555')] } as never); + const opensBefore = openCalls.length; + + assert.equal(registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); + assert.equal(registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); + assert.equal(registry.resolveSendTarget('444', 'discord-b'), undefined, 'not on another server'); + assert.equal(registry.resolveSendTarget('555'), undefined, 'a raw id two servers share'); + assert.equal(registry.resolveSendTarget('555', 'discord-b'), 'discord:h:555', 'the server settles it'); + assert.equal(registry.resolveSendTarget('999', 'discord-a'), undefined, 'an unknown id'); + assert.equal(openCalls.length, opensBefore, 'a lookup opens nothing'); +}); + test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { const { registry, failures } = makeRegistry({ delivered: true }); seedRegistered(registry, 'discord', 'ch-y'); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 7775323e3..2deada1ee 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -1,9 +1,10 @@ /** * Speaking never opens a channel: an explicit send tool's successful call - * into a channel the agent closed posts there (the server sends it) and - * leaves the channel's lifecycle as the agent left it, with no "[channels] - * Now open" notice. The call still records the channel as engaged this turn, - * by its descriptor id, for the mid-turn re-pin. + * posts (the server sends it) and leaves the channel's lifecycle as the agent + * left it, with no "[channels] Now open" notice: a channel the agent closed + * stays closed, and a tuned-out channel stays tuned out. The call still + * records the channel as engaged this turn, by its descriptor id, for the + * mid-turn re-pin. */ import { test } from 'node:test'; @@ -15,7 +16,12 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-gr import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; -test('a reply_message into a channel the agent closed leaves it closed, and records the turn engagement', async () => { +const channelId = 'discord:g1:444555'; +const origin = { kind: 'agent' as const, agentName: 'sol' }; + +/** A real ChannelRegistry with one registered channel, and a framework with + * just what dispatchMcplToolCall reads. */ +async function harness() { const opens: Array<{ channelId?: string }> = []; const server = { id: 'discord', @@ -36,19 +42,11 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco getServer: (id: string) => (id === 'discord' ? server : undefined), } as unknown as McplServerRegistry; const registry = new ChannelRegistry(serverRegistry, {} as FeatureSetManager, () => {}, () => {}); - - const channelId = 'discord:g1:444555'; await registry.handleChanged('discord', { added: [{ id: channelId, type: 'discord', label: '#upkeep', direction: 'bidirectional', address: { guildId: 'g1', channelId: '444555' } }], } as never); - // The agent had the channel open, then closed it. - const origin = { kind: 'agent' as const, agentName: 'sol' }; - assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); - assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); - const opensBefore = opens.length; - // A framework with just what dispatchMcplToolCall reads. const framework = Object.create(AgentFramework.prototype) as any; framework.mcplServerRegistry = serverRegistry; framework.mcplServerConfigs = new Map([['discord', { id: 'discord', toolPrefix: 'discord' }]]); @@ -63,29 +61,67 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco return 'msg'; }; - const result = await new Promise<{ success: boolean }>((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); - framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean } }) => { - if (event.type === 'tool-result' && event.callId === 'call-reply') { - clearTimeout(timeout); - resolve(event.result); - } - }; - framework.dispatchMcplToolCall( - 'sol', - { id: 'call-reply', name: 'discord--reply_message', input: { channelId: '444555', messageId: 'm1', content: 'answering, without moving in' } }, - 'discord', - 'discord', - ); - }); - // Let anything the send started on its own settle before looking. - await new Promise((resolve) => setImmediate(resolve)); + /** Dispatch reply_message by the raw id; resolve with its result and the + * turn's engaged channels as they stood when the result was queued (the + * mid-turn re-pin reads them when that event is processed). */ + async function reply(): Promise<{ success: boolean; engagedAtResult: string[] }> { + const out = await new Promise<{ success: boolean; engagedAtResult: string[] }>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); + framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean } }) => { + if (event.type === 'tool-result' && event.callId === 'call-reply') { + clearTimeout(timeout); + resolve({ + success: event.result.success, + engagedAtResult: [...(framework.turnEngagedChannels.get('sol') ?? [])], + }); + } + }; + framework.dispatchMcplToolCall( + 'sol', + { id: 'call-reply', name: 'discord--reply_message', input: { channelId: '444555', messageId: 'm1', content: 'answering, without moving in' } }, + 'discord', + 'discord', + ); + }); + // Let anything the send started on its own settle before the caller looks. + await new Promise((resolve) => setImmediate(resolve)); + return out; + } + + return { registry, opens, windowMessages, reply }; +} - assert.equal(result.success, true); +test('a reply_message into a channel the agent closed leaves it closed, and records the turn engagement', async () => { + const { registry, opens, windowMessages, reply } = await harness(); + // The agent had the channel open, then closed it. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + const opensBefore = opens.length; + + const { success, engagedAtResult } = await reply(); + + assert.equal(success, true); assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); assert.equal(registry.isChannelOpen(channelId), false, 'the channel stays closed'); assert.equal(registry.getDesiredState('discord', channelId), 'closed', "the agent's close stands"); assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); - assert.deepEqual([...(framework.turnEngagedChannels.get('sol') ?? [])], [channelId], - 'the raw id resolves to the descriptor id the re-pin compares'); + assert.deepEqual(engagedAtResult, [channelId], + 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); +}); + +test('a reply_message into a tuned-out channel leaves the tune-out in place', async () => { + const { registry, opens, windowMessages, reply } = await harness(); + // Tuned out: the transport stays open, and the decision is the tune-out. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + registry.enterTuneOut('discord', channelId, { + epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, + }, 'agent-tool'); + const opensBefore = opens.length; + + const { success } = await reply(); + + assert.equal(success, true); + assert.equal(registry.getDesiredState('discord', channelId), 'tuned-out', 'the tune-out stands'); + assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); + assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 20ae70d3c..2db4eab94 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -287,7 +287,9 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.handleMcplPushEvent(dmEvent('m4')); const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); - assert.match(t2, /Reply without joining isn't available here/); + assert.match(t2, /no single-word target reaches this channel unambiguously/); + assert.match(t2, /use the server's own send tool, or join it \(option 2\) and reply/, + 'replying needs no join: speaking never opens a channel'); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'joining stays available'); await fw2.stop(); From 75c3d3db01af8da07c5a913f0f98149b4bb50b34 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Tue, 13 Oct 2026 22:54:03 -0400 Subject: [PATCH 03/37] fix(mcpl): a close or decline of an undecided channel sticks; the invitation names the server's tool Answers a second internal review of this change. - setDesiredState let a decision that kept the state record nothing, so channel_close on an already-closed channel, and channel_decline of one, left a channel nobody had decided about at its 'default-closed' default, which subscription policy and a server's initiallyOpen lift. That made the close notice's and channel_close's "neither speaking there nor policy reopens it" false for such a channel. A real decision now replaces a pure default at the same state, and channel_close records its decision on an already-closed channel before answering "already closed". - The closed-channel invitation, where no single-word target reaches a channel, now says to reply with that server's own send tool, which can't post through another server when two share the id (a >> prefix, channel_publish and plain speech all find a channel by id alone), and carries the follow-up note the target option has. It no longer offers a join as the way to reply. - Comments no longer say DMs register closed, or credit the agent with a decision a never-decided channel holds by default; the send-path comment names discord-mcpl for closed channels delivering only addressed follow-ups. The explicit-prose doc says send tools and lifecycle work the same in explicit mode as in the other modes. Tests: close and decline of never-decided channels survive a later policy admission; speech publishes on a server without channels.lifecycle; a send into a channel whose open failed attempts no open; on channels/incoming a mention in a closed channel is invited and a DM is not. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 8 ++-- changelog.d/speaking-never-opens.fixed.md | 3 ++ docs/explicit-prose-routing.md | 4 +- src/framework.ts | 10 ++-- src/mcpl/channel-registry.ts | 50 ++++++++++++-------- test/channel-registry-routing.test.ts | 50 ++++++++++++++++++++ test/send-into-closed-channel.test.ts | 28 ++++++++++- test/trunk-channel-routing.test.ts | 43 ++++++++++++++++- 8 files changed, 162 insertions(+), 34 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 6740b2aa7..183f44a4c 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -14,9 +14,11 @@ agent whether the DM is open or not; a DM the agent answers now stays closed, so the invitation would otherwise repeat on every DM. A whisper tagged `chat:dm` inside a shared channel that is closed carries none either. Where no single-word target reaches a channel, - the invitation no longer says that replying needs a join. A send no longer re-runs the open - of a channel decided open whose transport is down: `channel_open` and the next registration's - reconciliation do that, as they do for a channel nobody speaks in. + the invitation no longer says that replying needs a join: it points to the server's own send + tool, and says that follow-ups reach the agent only when they address it. A send no longer + re-runs the open of a channel decided open whose transport is down: `channel_open` does + that, and so does the next registration's reconciliation for a channel its server + registers, as for a channel nobody speaks in. - **Unchanged:** typing indicators and reactions, which never depended on a channel being open. A registered channel with a stored decision to open, including one that an earlier send opened, reconciles open at its next registration, as before. A DM known only from push diff --git a/changelog.d/speaking-never-opens.fixed.md b/changelog.d/speaking-never-opens.fixed.md index 18a802bd8..61ae02c11 100644 --- a/changelog.d/speaking-never-opens.fixed.md +++ b/changelog.d/speaking-never-opens.fixed.md @@ -2,3 +2,6 @@ channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". - Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now publishes. Before, the open that preceded publishing threw, and the speech failed. +- `channel_close` and `channel_decline` on a channel nobody had decided about, which is closed by + default, now record the agent's decision. Before, the channel kept its default, so subscription + policy or a server's `initiallyOpen` could open it later. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index c160d58c2..af4956c2f 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -48,8 +48,8 @@ agent (then notices append without waking, breaking any loop). unprefixed prose bounces and prefixed prose is deliberate. - The entire "which event may move the agent's voice" question. -Explicit mode leaves the send tools (`send_message` etc.) and channel -lifecycle as they are in every mode. Speaking never opens a channel, so a +Send tools (`send_message` etc.) and channel lifecycle work the same in +explicit mode as in the other modes. Speaking never opens a channel, so a `>>` send into a closed channel publishes there and leaves it closed. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index e6f93fb56..1ac5a13cf 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9506,7 +9506,9 @@ export class AgentFramework { const replyOption = target ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` - : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; use the server's own send tool, or join it (option 2) and reply.\n`; + : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; ` + + `reply with ${opts.serverId}'s own send tool instead. ` + + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`; const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -17645,9 +17647,9 @@ export class AgentFramework { // carries the CANONICAL channel id (descriptor form), while send args // often carry the raw provider id, so the registry resolves it first. // The send opens nothing: speaking never opens a channel, so a closed - // channel stays closed, with its desired state as the agent left it. - // A closed channel delivers only follow-ups that address the agent, - // which re-pin on their own. + // channel stays closed, and its recorded decision stays as it was. + // Where a closed channel delivers only follow-ups that address the + // agent (discord-mcpl), those re-pin on their own. if ( this.channelRegistry && SEND_ENGAGEMENT_TOOLS.has(toolName) && diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 6c74c7307..0dbd6c717 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1942,7 +1942,11 @@ export class ChannelRegistry { source: string, ): void { const key = this.lifecycleKey(serverId, channelId); - if (this.desiredStates.get(key)?.state === desired) return; + const current = this.desiredStates.get(key); + // The same state again records nothing, except that a real decision + // replaces a pure default: a close or decline of a channel nobody decided + // about must stick, and policy and bootstrap lift only 'default-closed'. + if (current?.state === desired && (current.source !== 'default-closed' || source === 'default-closed')) return; this.desiredStates.set(key, { state: desired, source }); this.appendLifecycleEvent({ kind: 'desired-state', @@ -2387,10 +2391,10 @@ export class ChannelRegistry { * The open executor behind the agent's channel_open tool: record durable * desired-open intent, tell the server to subscribe, mark the live entry * open. Speaking never opens a channel, so no send or delivery comes here: - * an agent's speech into a closed channel publishes and leaves the - * channel's lifecycle as the agent left it. Desired state is recorded - * BEFORE the server round-trip: intent sticks even if the subscribe fails, - * and reconciliation retries later. + * an agent's speech into a closed channel publishes, and the channel's + * open state and recorded decision stay as they were. Desired state is + * recorded BEFORE the server round-trip: intent sticks even if the + * subscribe fails, and reconciliation retries later. */ private async openChannelNow( entry: ChannelEntry, @@ -2604,10 +2608,10 @@ export class ChannelRegistry { * tools receive whatever the agent typed. Matches on `serverId` when it is * given, across every server otherwise, and is undefined when nothing * matches or more than one channel does. A lookup only: speaking never - * opens a channel, so a send into a closed channel leaves it closed, with - * its desired state as the agent left it. Typing indicators and reactions - * don't depend on a channel being open; opening only subscribes to its - * ongoing traffic. + * opens a channel, so a send into a closed channel leaves it closed, and + * its recorded decision as it was. Typing indicators and reactions don't + * depend on a channel being open; opening only subscribes to its ongoing + * traffic. */ resolveSendTarget(channelId: string, serverId?: string): string | undefined { let matches = [...this.channels.values()].filter( @@ -2718,14 +2722,6 @@ export class ChannelRegistry { }; } - const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed'; - if (!entry.open && alreadyDesiredClosed) { - return { - success: true, - data: { channelId: input.channelId, status: 'already closed' }, - }; - } - // Provenance comes from trusted dispatch, not from self-description: // only module-origin calls may record a machine source, and only from // the closed vocabulary. Agent-origin (or origin-less — the safe @@ -2746,6 +2742,18 @@ export class ChannelRegistry { closeSource = input.source; } + const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed'; + if (!entry.open && alreadyDesiredClosed) { + // Nothing to send, but a close of a channel nobody decided about is + // still a decision: recording it keeps policy and bootstrap, which lift + // only a pure default, from opening the channel later. + this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); + return { + success: true, + data: { channelId: input.channelId, status: 'already closed' }, + }; + } + // Housekeeping must not override stated intent (issue #5: GC closes wore // the agent's badge and reset explicitly-opened doors). A machine-sourced // close of a channel whose current desired state is an agent/operator @@ -3076,10 +3084,10 @@ export class ChannelRegistry { } // Speaking never opens a channel: speech into a closed but registered - // locus (a DM that woke the agent, since DMs register closed) publishes - // and leaves the channel's lifecycle as the agent left it. Publishing - // doesn't need an open channel, and neither do typing and reactions; - // opening only subscribes to ongoing traffic, which is channel_open's. + // locus (such as a DM that woke the agent) publishes, and the channel's + // open state and recorded decision stay as they were. Publishing doesn't + // need an open channel, and neither do typing and reactions; opening + // only subscribes to ongoing traffic, which is channel_open's. const server = this.serverRegistry.getServer(entry.serverId); if (!server) { return fail(channelId, `server "${entry.serverId}" not found`); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index bb60a8ee5..704419e7d 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -341,6 +341,56 @@ test('routeSpeech into a closed channel publishes and leaves it closed (speaking assert.equal(autoOpened.length, 0, 'nothing is announced as opened'); }); +test('routeSpeech into a closed channel publishes on a server whose grant lacks channels.lifecycle', async () => { + const published: Array = []; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), []), + sendChannelsPublish: async (params: { channelId?: string }) => { + published.push(params.channelId); + return { delivered: true }; + }, + }; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + ); + seedRegistered(registry, 'portal', 'lobby'); + + const res = await registry.routeSpeech('sol', 'hello from outside', 'lobby'); + + assert.deepEqual(res, { delivered: true, channelId: 'lobby' }, 'no open stands in the way'); + assert.deepEqual(published, ['lobby']); +}); + +test('a close or decline of a channel nobody decided about sticks against later policy', async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + const added = { + added: [ + { id: 'closed-by-agent', type: 'discord', label: '#a', direction: 'bidirectional' }, + { id: 'declined-by-agent', type: 'discord', label: '#b', direction: 'bidirectional' }, + ], + } as never; + // Registered closed by default: nobody has decided about either. + await registry.handleChanged('discord', added); + const origin = { kind: 'agent' as const, agentName: 'sol' }; + const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'closed-by-agent' }, origin); + assert.deepEqual(closed.data, { channelId: 'closed-by-agent', status: 'already closed' }); + const declined = await registry.handleChannelToolCall( + 'channel_decline', { channelId: 'declined-by-agent', messageId: 'm1' }, origin); + assert.equal(declined.success, true); + + // Policy now admits every channel, and both register again. + registry.setSubscriptionPolicy('discord', 'auto'); + const opensBefore = openCalls.length; + await registry.handleChanged('discord', added); + + assert.equal(registry.getDesiredState('discord', 'closed-by-agent'), 'closed', 'the close sticks'); + assert.equal(registry.getDesiredState('discord', 'declined-by-agent'), 'closed', 'the decline sticks'); + assert.equal(openCalls.length, opensBefore, 'policy opens neither'); +}); + test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = (id: string, raw: string) => ({ diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 2deada1ee..f6d2f4276 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -2,7 +2,8 @@ * Speaking never opens a channel: an explicit send tool's successful call * posts (the server sends it) and leaves the channel's lifecycle as the agent * left it, with no "[channels] Now open" notice: a channel the agent closed - * stays closed, and a tuned-out channel stays tuned out. The call still + * stays closed, a channel whose open failed isn't opened again, and a + * tuned-out channel stays tuned out. The call still * records the channel as engaged this turn, by its descriptor id, for the * mid-turn re-pin. */ @@ -22,7 +23,9 @@ const origin = { kind: 'agent' as const, agentName: 'sol' }; /** A real ChannelRegistry with one registered channel, and a framework with * just what dispatchMcplToolCall reads. */ async function harness() { + // Every channels/open attempt, including one the server refuses. const opens: Array<{ channelId?: string }> = []; + let refuseOpens = false; const server = { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), @@ -32,6 +35,7 @@ async function harness() { }, async sendChannelsOpen(params: { channelId?: string }) { opens.push(params); + if (refuseOpens) throw new Error('open refused by server'); return {}; }, async sendChannelsClose() { @@ -88,7 +92,10 @@ async function harness() { return out; } - return { registry, opens, windowMessages, reply }; + return { + registry, opens, windowMessages, reply, + setRefuseOpens: (v: boolean) => { refuseOpens = v; }, + }; } test('a reply_message into a channel the agent closed leaves it closed, and records the turn engagement', async () => { @@ -109,6 +116,23 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); }); +test('a reply_message into a channel decided open whose open failed retries no open', async () => { + const { registry, opens, windowMessages, reply, setRefuseOpens } = await harness(); + // The agent opened it, but the server refused: decided open, transport closed. + setRefuseOpens(true); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, false); + assert.equal(registry.getDesiredState('discord', channelId), 'open'); + assert.equal(registry.isChannelOpen(channelId), false); + const opensBefore = opens.length; + + const { success } = await reply(); + + assert.equal(success, true); + assert.equal(opens.length, opensBefore, 'the send attempts no channels/open'); + assert.equal(registry.isChannelOpen(channelId), false); + assert.deepEqual(windowMessages, []); +}); + test('a reply_message into a tuned-out channel leaves the tune-out in place', async () => { const { registry, opens, windowMessages, reply } = await harness(); // Tuned out: the transport stays open, and the decision is the tune-out. diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 2db4eab94..fa280f74a 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -35,6 +35,7 @@ function internals(framework: AgentFramework) { ): { channelId: string; label?: string } | undefined; channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; + handleMcplChannelIncoming(event: unknown): Promise; }; } @@ -288,8 +289,8 @@ describe('Trunk channel routing (item-3 redux)', () => { const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); assert.match(t2, /no single-word target reaches this channel unambiguously/); - assert.match(t2, /use the server's own send tool, or join it \(option 2\) and reply/, - 'replying needs no join: speaking never opens a channel'); + assert.match(t2, /reply with discord's own send tool instead\. Note: follow-ups to your reply will NOT reach you/, + "replying needs no join, and the server's own tool can't take another server's channel"); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'joining stays available'); await fw2.stop(); @@ -342,6 +343,44 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('on channels/incoming too, a mention in a closed channel is invited and a DM is not', async () => { + const framework = await makeFramework(); + const i = internals(framework); + i.channelRegistry = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + getDesiredState: () => undefined, + getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), + proseTargetFor: () => '#portables', + stopAll: () => {}, + }; + const lastText = () => + framework.getAgent('scout')!.getContextManager().queryMessages({}).messages.at(-1)?.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('\n') ?? ''; + const incomingOn = (channelId: string, text: string, tags: string[]) => i.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', + serverId: 'discord', + channelId, + messageId: `m-${Math.random().toString(36).slice(2)}`, + author: { id: 'U1', name: 'antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: {}, + tags, + triggerInference: false, + }); + + await incomingOn('discord:G1:C7', 'antra: a question for you', ['chat:mention', 'chat:addressed']); + assert.match(lastText(), /Channel invitation/, 'this path builds the invitation'); + + await incomingOn('discord:dm:1555', 'antra: and one in private', ['chat:dm', 'chat:addressed', 'chat:private']); + assert.match(lastText(), /one in private/); + assert.doesNotMatch(lastText(), /Channel invitation/); + await framework.stop(); + }); + it('a channel-incoming trunk turn records its triggering channel', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); From 98a877a3ae25b025f99a8249abe7f79187f2c2c5 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Tue, 13 Oct 2026 23:35:11 -0400 Subject: [PATCH 04/37] fix(mcpl): a heartbeat still speaks where the agent answered; only the agent's close replaces a default Answers the readers of the previous head. - Where a turn with no trigger speaks follows the agent, as on main. On main, answering a closed channel opened it, so its later channels/incoming messages moved the most recent inbound channel, the fallback a heartbeat speaks to. The channel now stays closed, and on discord-mcpl its messages that address the agent keep arriving as push events, which never moved the fallback, so a heartbeat would have spoken into the last open channel with traffic instead of an answered DM. The registry now marks a channel answered (noteAnswered) when the agent's speech or a send tool delivers into it while closed, on its desired-state record, so the mark survives a restart. A push accepted from an answered channel moves the fallback as an accepted channels/incoming message does (notePushDelivered): an ordinary push once its trigger filter returns, a coalesced one only when it is admitted afresh, never for a producer's retry or a withdrawal, and never a reaction or a deletion. The next decision about the channel (an open, a close, a decline or a tune-out) ends the mark. - Only the agent's own close or decline replaces a pure default at the same state. A machine close (subscription-gc, housekeeping) of a channel closed by default records nothing, so policy can still lift it; connectome-host's subscription-gc closes such channels on connectors that deliver ambient traffic from MCPL-closed channels. - On discord-mcpl, channels/open turns a channel's reaction forwarding on and channels/close turns it off, so comments no longer say reactions don't depend on a channel being open, and the breaking fragment says reactions to a reply in a closed guild channel no longer arrive unless the agent opens it or calls set_reaction_visibility. - The closed-channel invitation's no-target option suggests checking whether the server has a send tool that can address the channel, rather than promising one. Tests: a new file drives the fallback over a real MCPL wire (the coalescing fixture): an answered DM's next push moves it and a no-trigger turn's speech lands there; a push from a channel nobody answered, and a reaction, move nothing; the agent's close ends it; it survives a framework restart over the same store; a coalesced push counts when admitted, not on a retry or a withdrawal. The send test checks a send marks the channel answered, and the close/decline test adds a subscription-gc close that policy still lifts. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 43 +++++-- changelog.d/speaking-never-opens.fixed.md | 8 +- src/framework.ts | 34 +++++- src/mcpl/channel-registry.ts | 90 ++++++++++++-- test/answered-channel-fallback.test.ts | 118 +++++++++++++++++++ test/channel-registry-routing.test.ts | 14 ++- test/send-into-closed-channel.test.ts | 5 + test/trunk-channel-routing.test.ts | 4 +- 8 files changed, 281 insertions(+), 35 deletions(-) create mode 100644 test/answered-channel-fallback.test.ts diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 183f44a4c..b19e60588 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -7,6 +7,19 @@ - **Who needs to act:** an agent that wants a channel's ongoing traffic after speaking there opens it with `channel_open`; speaking no longer does. Messages that address the agent still arrive while a channel is closed. + - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction + forwarding, and closing it turns that off. A reply in a closed guild channel used to open + it, and so turn its reactions on. Now reactions to the reply arrive only where the agent + opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: + discord-mcpl requests no DM reaction events. + - **What hosts notice:** on discord-mcpl, an answered channel's later messages that address + the agent arrive as push events, as any closed channel's do, instead of `channels/incoming`. + Host code that acts on `channels/incoming`, such as connectome-host's outage reply and typing + hints, doesn't see them. Where a turn with no trigger (a heartbeat) speaks is unchanged: a + push from a channel the agent answered while it was closed moves the most recent inbound + channel, as an open channel's `channels/incoming` does. That holds across a restart, until + the next decision about the channel (an open, a close, a decline or a tune-out). A push from + any other closed channel moves nothing, as before. - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s description now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel @@ -14,20 +27,24 @@ agent whether the DM is open or not; a DM the agent answers now stays closed, so the invitation would otherwise repeat on every DM. A whisper tagged `chat:dm` inside a shared channel that is closed carries none either. Where no single-word target reaches a channel, - the invitation no longer says that replying needs a join: it points to the server's own send - tool, and says that follow-ups reach the agent only when they address it. A send no longer - re-runs the open of a channel decided open whose transport is down: `channel_open` does - that, and so does the next registration's reconciliation for a channel its server - registers, as for a channel nobody speaks in. - - **Unchanged:** typing indicators and reactions, which never depended on a channel being - open. A registered channel with a stored decision to open, including one that an earlier - send opened, reconciles open at its next registration, as before. A DM known only from push - events is never reconciled: one that an earlier send opened used to reopen on the agent's - next reply after a restart, and now stays closed until the agent opens it. + the invitation no longer says that replying needs a join: it suggests checking whether the + server has a send tool that can address the channel, and says that follow-ups reach the + agent only when they address it. A send no longer re-runs the open of a channel decided open + whose transport is down: `channel_open` does that, and so does the next registration's + reconciliation for a channel its server registers, as for a channel nobody speaks in. + - **Unchanged:** typing indicators, which never depended on a channel being open. A registered + channel with a stored decision to open, including one that an earlier send opened, + reconciles open at its next registration, as before. A DM known only from push events is + never reconciled: one that an earlier send opened used to reopen on the agent's next reply + after a restart, and now stays closed until the agent opens it. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went - to, from a composite or raw id, and opens nothing. `onChannelAutoOpened` reports only - `source: 'subscription-policy'`, and its `conversationId` is gone. The - `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. + to, from a composite or raw id, and opens nothing. `noteAnswered(channelId, serverId?)` and + `notePushDelivered(serverId, channelId, messageId?, threadId?, tags?)` are new: the framework + calls them when the agent answers a closed channel and when a push is accepted. + `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is + gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer + emitted. A `desired-state` record in the channel lifecycle log can carry `answered: true`; + earlier versions ignore the field. - **Prompt cache:** `channel_close`'s description changes, so the first request after the upgrade that carries the channel tools misses the cache from its tool block on. diff --git a/changelog.d/speaking-never-opens.fixed.md b/changelog.d/speaking-never-opens.fixed.md index 61ae02c11..1262d1e1e 100644 --- a/changelog.d/speaking-never-opens.fixed.md +++ b/changelog.d/speaking-never-opens.fixed.md @@ -2,6 +2,8 @@ channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". - Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now publishes. Before, the open that preceded publishing threw, and the speech failed. -- `channel_close` and `channel_decline` on a channel nobody had decided about, which is closed by - default, now record the agent's decision. Before, the channel kept its default, so subscription - policy or a server's `initiallyOpen` could open it later. +- The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which + is closed by default, is now recorded as its decision. Before, the channel kept its default, + so subscription policy or a server's `initiallyOpen` could open it later. A machine close of + such a channel, such as connectome-host's `subscription-gc`, still records nothing, so policy + can still open it. diff --git a/src/framework.ts b/src/framework.ts index 1ac5a13cf..40a593991 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9507,7 +9507,7 @@ export class AgentFramework { ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; ` + - `reply with ${opts.serverId}'s own send tool instead. ` + + `check whether ${opts.serverId} exposes a send tool that can address it. ` + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`; const missedCount = opts.missedMessages ?? 0; const missedNote = @@ -9729,7 +9729,11 @@ export class AgentFramework { if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId); const origin = params.origin ?? {}; const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); - return this.pushCoalescer.accept({ + // Admitted afresh, as markAccepted counts: a withdrawal, or a producer's + // retry of work already acknowledged, is never where the last message + // came in. + const fresh = !c.retract && !this.pushCoalescer.receipt(serverId, this.coalescingBinding(serverId), params.eventId); + const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), featureSet: params.featureSet, @@ -9752,9 +9756,26 @@ export class AgentFramework { } : {}), event: { lane: 'push', event }, }); + if (fresh) this.notePushAccepted(event, c.channelId); + return result; }); } + /** + * A push the host accepted, from the channel it belongs to (`channelId`, or + * the one its origin names): from a channel the agent answered while it was + * closed, it is where the last message came in, as an accepted + * channels/incoming message is (ChannelRegistry.notePushDelivered). + */ + private notePushAccepted(event: McplPushEvent, channelId?: string): void { + if (!this.channelRegistry) return; + const id = channelId ?? this.derivePushEventChannel(event.origin)?.channelId; + if (!id) return; + const origin = (event.origin ?? {}) as Record; + const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + this.channelRegistry.notePushDelivered(event.serverId, id, str(origin.messageId), str(origin.threadId), event.tags); + } + /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */ private assertChannelScopedPush(serverId: string, channelId: string): void { const server = this.mcplServerRegistry?.getServer(serverId); @@ -15656,7 +15677,11 @@ export class AgentFramework { this.initializePushCoalescer(); this.pushHandler = new PushHandler( this.featureSetManager, - (event) => this.pushEvent(event as unknown as ProcessEvent), + // Called once a push is accepted, its trigger filter returned. + (event) => { + this.notePushAccepted(event); + this.pushEvent(event as unknown as ProcessEvent); + }, (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), @@ -17666,6 +17691,9 @@ export class AgentFramework { this.turnEngagedChannels.set(agentName, engaged); } engaged.add(engagedChannel); + // A closed channel stays closed, and where a turn with no trigger + // speaks still follows the agent there. + this.channelRegistry.noteAnswered(engagedChannel, serverId); } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 0dbd6c717..360f54351 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -110,6 +110,9 @@ interface ChannelLifecycleEvent { epochId?: string; wakeCount?: number; source?: string; + /** kind 'desired-state': the agent answered the channel while it was + * closed (noteAnswered). The next desired-state record drops it. */ + answered?: true; messageId?: string; acknowledgment?: string; } @@ -673,6 +676,9 @@ export class ChannelRegistry { /** Durable wake count for the active tune-out epoch (replayed from * 'tune-out-wake' lifecycle records; see recordTuneOutWake). */ wakeCount?: number; + /** The agent answered the channel while it was closed (noteAnswered), + * and nothing has decided about it since. */ + answered?: true; }>(); /** One-time migration inputs from the retired recipe auto-open policy. */ @@ -1575,6 +1581,7 @@ export class ChannelRegistry { source: typeof event.source === "string" ? event.source : "unknown", tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined, wakeCount: 0, + ...(event.answered === true ? { answered: true as const } : {}), }, ); } else if ( @@ -1943,21 +1950,80 @@ export class ChannelRegistry { ): void { const key = this.lifecycleKey(serverId, channelId); const current = this.desiredStates.get(key); - // The same state again records nothing, except that a real decision - // replaces a pure default: a close or decline of a channel nobody decided - // about must stick, and policy and bootstrap lift only 'default-closed'. - if (current?.state === desired && (current.source !== 'default-closed' || source === 'default-closed')) return; - this.desiredStates.set(key, { state: desired, source }); + let recorded = source; + if (current?.state === desired) { + // The same state keeps the decision on record, except that the agent's + // own close or decline replaces a pure default, so it sticks: policy + // and bootstrap lift only 'default-closed'. A machine source never + // replaces one, so what nobody decided stays liftable. Either way the + // write ends an answer (noteAnswered), as a close ended main's open. + recorded = current.source === 'default-closed' && !MACHINE_CLOSE_SOURCES.has(source) + ? source + : current.source; + if (recorded === current.source && !current.answered) return; + } + this.desiredStates.set(key, { state: desired, source: recorded }); this.appendLifecycleEvent({ kind: 'desired-state', serverId, channelId, desired, + source: recorded, + timestamp: new Date().toISOString(), + }); + } + + /** + * The agent's speech, or a send tool's call, delivered into this channel + * while it was closed. Speaking never opens a channel, but where the + * agent's next turn with no trigger speaks follows the channels it answered, + * as it did when answering opened them: a push from an answered channel + * moves the most recent inbound channel (notePushDelivered), as an open + * channel's channels/incoming does. Kept on the desired-state record, so it + * survives a restart, until the next decision about the channel: an open, + * a close, a decline or a tune-out. + */ + noteAnswered(channelId: string, serverId?: string): void { + const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); + if (!entry || entry.open) return; + const key = this.lifecycleKey(entry.serverId, entry.descriptor.id); + const current = this.desiredStates.get(key); + if (current?.answered || current?.state === 'tuned-out') return; + const state = current?.state ?? 'closed'; + const source = current?.source ?? 'default-closed'; + this.desiredStates.set(key, { ...current, state, source, answered: true }); + this.appendLifecycleEvent({ + kind: 'desired-state', + serverId: entry.serverId, + channelId: entry.descriptor.id, + desired: state, source, + answered: true, timestamp: new Date().toISOString(), }); } + /** + * A push was accepted from this channel. One from a channel the agent + * answered while it was closed (noteAnswered) is where the last message + * came in, as an accepted channels/incoming message is (markAccepted), so + * a turn with no trigger speaks there. A push from any other channel moves + * nothing, as before, and neither does a reaction or a deletion. + */ + notePushDelivered( + serverId: string, + channelId: string, + messageId?: string, + threadId?: string, + tags?: readonly string[], + ): void { + if (!this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.answered) return; + if (tags?.some((t) => t === 'chat:reaction' || t === 'chat:reaction-remove' || t === 'chat:deleted')) return; + this.defaultPublishChannel = channelId; + this.defaultPublishMessageId = messageId ?? null; + this.defaultPublishThreadId = threadId; + } + // ========================================================================== // Tune-out state (issue #77) — durable in the lifecycle log // ========================================================================== @@ -2609,9 +2675,9 @@ export class ChannelRegistry { * given, across every server otherwise, and is undefined when nothing * matches or more than one channel does. A lookup only: speaking never * opens a channel, so a send into a closed channel leaves it closed, and - * its recorded decision as it was. Typing indicators and reactions don't - * depend on a channel being open; opening only subscribes to its ongoing - * traffic. + * its recorded decision as it was. Typing indicators don't depend on a + * channel being open; opening subscribes to its ongoing traffic (on + * discord-mcpl, its reactions too). */ resolveSendTarget(channelId: string, serverId?: string): string | undefined { let matches = [...this.channels.values()].filter( @@ -3086,8 +3152,9 @@ export class ChannelRegistry { // Speaking never opens a channel: speech into a closed but registered // locus (such as a DM that woke the agent) publishes, and the channel's // open state and recorded decision stay as they were. Publishing doesn't - // need an open channel, and neither do typing and reactions; opening - // only subscribes to ongoing traffic, which is channel_open's. + // need an open channel, and neither does typing; opening subscribes to + // the channel's ongoing traffic (on discord-mcpl, its reactions too), + // which is channel_open's. const server = this.serverRegistry.getServer(entry.serverId); if (!server) { return fail(channelId, `server "${entry.serverId}" not found`); @@ -3118,6 +3185,9 @@ export class ChannelRegistry { if (delivered === false) { return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`); } + // Answering a closed channel leaves it closed, and where a turn with no + // trigger speaks still follows the agent there (noteAnswered). + if (!entry.open) this.noteAnswered(channelId, entry.serverId); console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`); this.emitTraceFn({ diff --git a/test/answered-channel-fallback.test.ts b/test/answered-channel-fallback.test.ts new file mode 100644 index 000000000..210a7f469 --- /dev/null +++ b/test/answered-channel-fallback.test.ts @@ -0,0 +1,118 @@ +/** + * Speaking never opens a channel, so a channel the agent answers stays + * closed, and its later messages keep arriving as push events. Where a turn + * with no trigger (a heartbeat) speaks still follows the agent there: on main + * the answer opened the channel, and its channels/incoming messages moved the + * most recent inbound channel. A push from a channel the agent answered while + * it was closed now moves it the same way, until the next decision about the + * channel; a push from any other channel moves nothing, as before. The test + * runs over a real MCPL wire (a loopback server) into the framework's own + * ChannelRegistry. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { fixture, TS } from './helpers/coalescing-fixture.js'; +import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; + +type Fixture = Awaited>; + +const registryOf = (f: Fixture) => (f.framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + +/** A message from `channelId` that addresses the agent, as a closed channel's + * connector sends it; `coalesce` makes it a channel-scoped coalesced push. */ +const push = (eventId: string, channelId: string, tags: string[], coalesce?: Record) => ({ + featureSet: 'doc', + eventId, + timestamp: TS, + origin: { mcplChannelId: channelId, messageId: `m-${eventId}`, authorId: 'u', authorName: 'User' }, + tags, + payload: { content: [{ type: 'text', text: `message ${eventId}` }] }, + ...(coalesce ? { coalesce: { channelId, key: `message:m-${eventId}`, ...coalesce } } : {}), +}); + +async function setUp(f: Fixture) { + await f.send('channels/register', { + channels: ['lobby', 'dm', 'quiet'].map((id) => ({ id, type: 'discord', label: id })), + }); + // An open channel's message: the most recent inbound channel is the lobby. + await f.send('channels/incoming', { + messages: [{ channelId: 'lobby', messageId: 'l1', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'hello all' }] }], + }); + const registry = registryOf(f); + assert.equal(registry.getDefaultPublishChannel(), 'lobby'); + assert.equal(registry.isChannelOpen('dm'), false); + return registry; +} + +test('a push from a channel the agent answered while closed is where a turn with no trigger speaks', async (t) => { + const f = await fixture(); t.after(f.close); + const registry = await setUp(f); + + // Petra's control: a closed channel nobody answered moves nothing. + await f.send('push/event', push('q1', 'quiet', ['chat:mention', 'chat:addressed'])); + assert.equal(registry.getDefaultPublishChannel(), 'lobby'); + + // The agent answers the DM; it stays closed. + assert.deepEqual(await registry.routeSpeech('agent', 'happy to help', 'dm'), { delivered: true, channelId: 'dm' }); + assert.equal(registry.isChannelOpen('dm'), false); + + // A reaction to the answer isn't where a message came in. + await f.send('push/event', push('r1', 'dm', ['chat:reaction'])); + assert.equal(registry.getDefaultPublishChannel(), 'lobby'); + + // The DM's next message arrives by push, and a turn with no trigger speaks there. + await f.send('push/event', push('d1', 'dm', ['chat:dm', 'chat:addressed', 'chat:private'])); + assert.equal(registry.getDefaultPublishChannel(), 'dm'); + assert.equal(registry.resolveLocus('agent'), 'dm'); + await registry.routeSpeech('agent', 'the heartbeat speaks', registry.resolveLocus('agent')); + assert.equal(f.published.at(-1)?.channelId, 'dm'); + + // The agent's close is the next decision about it: the answer ends there. + await f.send('channels/incoming', { + messages: [{ channelId: 'lobby', messageId: 'l2', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'again' }] }], + }); + await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, { kind: 'agent', agentName: 'agent' }); + await f.send('push/event', push('d2', 'dm', ['chat:dm', 'chat:addressed', 'chat:private'])); + assert.equal(registry.getDefaultPublishChannel(), 'lobby'); +}); + +test('the answer survives a restart: after a reload, a push from the answered channel still moves the fallback', async (t) => { + const f = await fixture(); t.after(f.close); + const registry = await setUp(f); + await registry.routeSpeech('agent', 'happy to help', 'dm'); + + await f.framework.stop(); + await f.create(); + const reloaded = await setUp(f); + assert.notEqual(reloaded, registry, 'a new framework, over the same store'); + + await f.send('push/event', push('q1', 'quiet', ['chat:mention', 'chat:addressed'])); + assert.equal(reloaded.getDefaultPublishChannel(), 'lobby'); + await f.send('push/event', push('d1', 'dm', ['chat:dm', 'chat:addressed'])); + assert.equal(reloaded.getDefaultPublishChannel(), 'dm'); +}); + +test("a coalesced push counts once, when it's admitted afresh, and a withdrawal never does", async (t) => { + const f = await fixture(); t.after(f.close); + const registry = await setUp(f); + await registry.routeSpeech('agent', 'happy to help', 'dm'); + + const first = push('c1', 'dm', ['chat:dm', 'chat:addressed'], { initial: true }); + await f.send('push/event', first); + assert.equal(registry.getDefaultPublishChannel(), 'dm', 'the channel-scoped push a connector sends for a closed channel'); + + // The lobby speaks; then the producer retries the DM push it already sent. + await f.send('channels/incoming', { + messages: [{ channelId: 'lobby', messageId: 'l2', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'again' }] }], + }); + await f.send('push/event', first); + assert.equal(registry.getDefaultPublishChannel(), 'lobby', "a retry of acknowledged work isn't a new message"); + + // A withdrawal of the DM's message, tagged as a message so only the + // withdrawal itself can keep it from counting. + const withdrawal = { ...push('c2', 'dm', ['chat:dm', 'chat:addressed']), coalesce: { channelId: 'dm', key: 'message:m-c1', retract: true }, payload: { content: [] } }; + const r = await f.send('push/event', withdrawal); + assert.equal(r.error, undefined, 'the withdrawal is accepted'); + assert.equal(registry.getDefaultPublishChannel(), 'lobby', 'a withdrawal is never where a message came in'); +}); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 704419e7d..9699d4a9f 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -364,15 +364,16 @@ test('routeSpeech into a closed channel publishes on a server whose grant lacks assert.deepEqual(published, ['lobby']); }); -test('a close or decline of a channel nobody decided about sticks against later policy', async () => { +test("the agent's close or decline of a channel nobody decided about sticks against later policy; a machine close doesn't", async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = { added: [ { id: 'closed-by-agent', type: 'discord', label: '#a', direction: 'bidirectional' }, { id: 'declined-by-agent', type: 'discord', label: '#b', direction: 'bidirectional' }, + { id: 'closed-by-gc', type: 'discord', label: '#c', direction: 'bidirectional' }, ], } as never; - // Registered closed by default: nobody has decided about either. + // Registered closed by default: nobody has decided about any of them. await registry.handleChanged('discord', added); const origin = { kind: 'agent' as const, agentName: 'sol' }; const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'closed-by-agent' }, origin); @@ -380,15 +381,20 @@ test('a close or decline of a channel nobody decided about sticks against later const declined = await registry.handleChannelToolCall( 'channel_decline', { channelId: 'declined-by-agent', messageId: 'm1' }, origin); assert.equal(declined.success, true); + // connectome-host's subscription-gc closes an idle channel in this shape. + const gc = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'closed-by-gc', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + assert.deepEqual(gc.data, { channelId: 'closed-by-gc', status: 'already closed' }); - // Policy now admits every channel, and both register again. + // Policy now admits every channel, and all three register again. registry.setSubscriptionPolicy('discord', 'auto'); const opensBefore = openCalls.length; await registry.handleChanged('discord', added); assert.equal(registry.getDesiredState('discord', 'closed-by-agent'), 'closed', 'the close sticks'); assert.equal(registry.getDesiredState('discord', 'declined-by-agent'), 'closed', 'the decline sticks'); - assert.equal(openCalls.length, opensBefore, 'policy opens neither'); + assert.equal(registry.getDesiredState('discord', 'closed-by-gc'), 'open', 'a machine close decided nothing'); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['closed-by-gc'], 'policy opens only that one'); }); test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index f6d2f4276..e7b34c4fa 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -114,6 +114,11 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); assert.deepEqual(engagedAtResult, [channelId], 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); + + // The channel's next message, by push, is where a turn with no trigger speaks. + assert.equal(registry.getDefaultPublishChannel(), null); + registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); + assert.equal(registry.getDefaultPublishChannel(), channelId, 'the send answered the channel'); }); test('a reply_message into a channel decided open whose open failed retries no open', async () => { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index fa280f74a..6deadfa0b 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -289,8 +289,8 @@ describe('Trunk channel routing (item-3 redux)', () => { const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); assert.match(t2, /no single-word target reaches this channel unambiguously/); - assert.match(t2, /reply with discord's own send tool instead\. Note: follow-ups to your reply will NOT reach you/, - "replying needs no join, and the server's own tool can't take another server's channel"); + assert.match(t2, /check whether discord exposes a send tool that can address it\. Note: follow-ups to your reply will NOT reach you/, + 'replying needs no join, and the hint promises no tool'); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'joining stays available'); await fw2.stop(); From 7d9c4be3886ee2f7b0a85f00d565e284eaa05321 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Tue, 13 Oct 2026 23:39:18 -0400 Subject: [PATCH 05/37] test(mcpl): the lookup test awaits resolveSendTarget's results So that, in a red-first run, main's async openIfClosedForSend can stand in for the lookup through a small adapter, and the test fails on the open it makes rather than on the missing method. On this branch the lookup is synchronous, and awaiting its result changes nothing. Co-Authored-By: Claude Opus 5.5 --- test/channel-registry-routing.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 9699d4a9f..3e235fc26 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -405,13 +405,14 @@ test('resolveSendTarget names the channel a send went to and guesses nothing', a await registry.handleChanged('discord-a', { added: [added('discord:g:444', '444'), added('discord:g:555', '555')] } as never); await registry.handleChanged('discord-b', { added: [added('discord:h:555', '555')] } as never); const opensBefore = openCalls.length; - - assert.equal(registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); - assert.equal(registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); - assert.equal(registry.resolveSendTarget('444', 'discord-b'), undefined, 'not on another server'); - assert.equal(registry.resolveSendTarget('555'), undefined, 'a raw id two servers share'); - assert.equal(registry.resolveSendTarget('555', 'discord-b'), 'discord:h:555', 'the server settles it'); - assert.equal(registry.resolveSendTarget('999', 'discord-a'), undefined, 'an unknown id'); + // Awaited, so main's openIfClosedForSend can stand in for it in a red-first run. + + assert.equal(await registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); + assert.equal(await registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); + assert.equal(await registry.resolveSendTarget('444', 'discord-b'), undefined, 'not on another server'); + assert.equal(await registry.resolveSendTarget('555'), undefined, 'a raw id two servers share'); + assert.equal(await registry.resolveSendTarget('555', 'discord-b'), 'discord:h:555', 'the server settles it'); + assert.equal(await registry.resolveSendTarget('999', 'discord-a'), undefined, 'an unknown id'); assert.equal(openCalls.length, opensBefore, 'a lookup opens nothing'); }); From 9f3704fcf4202b90174db4906b987deff9499add Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Wed, 14 Oct 2026 05:31:08 -0400 Subject: [PATCH 06/37] fix(mcpl): a coalesced push counts when the coalescer admits it; an answer counts only while closed Answers an internal review of the previous two rounds. - Whether a coalesced push was admitted afresh is now decided inside the coalescer, under its lock: its host gains an `accepted` hook, called once per admitted occurrence after its receipt is recorded and never for a producer's retry (the same hook #256 adds), and the framework counts a push there. The receipt check before accept that it replaces couldn't tell an admission whose durable commit failed from acknowledged work, so that push's retry was never counted, and a receipt could expire between the check and the lock. - notePushDelivered counts a push only while the answered channel's transport is closed. A channel whose transport opened since (the server delivered channels/incoming from it, or reconcile reopened it) moves the fallback through channels/incoming, as on main, and its pushes, such as edits, don't. - noteAnswered runs after a delivered publish, so a store that fails to record the mark is logged rather than thrown, and the publish stands. Its serverId is required; both callers pass it. - The breaking fragment says an answered guild channel's messages that don't address the agent no longer arrive, so only those that do move the fallback (a DM's all do), names where noteAnswered is called, the coalescer's new hook, and that a module's close with an unknown machine source is refused even on a closed channel. The fixed fragment says a machine close records no decision, rather than nothing, since it ends an answer. Tests: a registry test ends an answer by each later decision (an open, a decline, a tune-out, a machine close, which leaves the default liftable, and the agent's close over its own earlier close); another checks a refused publish marks nothing, that reactions, reaction removals and deletions don't count, and that an answered channel whose transport opened counts its channels/incoming instead of its pushes. The coalesced test adds a feature-set-scoped push whose channel comes from its origin. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 21 +++--- changelog.d/speaking-never-opens.fixed.md | 4 +- src/framework.ts | 16 +++-- src/mcpl/channel-registry.ts | 43 +++++++----- src/mcpl/push-coalescer.ts | 9 +++ test/answered-channel-fallback.test.ts | 8 +++ test/channel-registry-routing.test.ts | 69 +++++++++++++++++++- test/send-into-closed-channel.test.ts | 3 +- 8 files changed, 137 insertions(+), 36 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index b19e60588..77229fe71 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -15,11 +15,13 @@ - **What hosts notice:** on discord-mcpl, an answered channel's later messages that address the agent arrive as push events, as any closed channel's do, instead of `channels/incoming`. Host code that acts on `channels/incoming`, such as connectome-host's outage reply and typing - hints, doesn't see them. Where a turn with no trigger (a heartbeat) speaks is unchanged: a - push from a channel the agent answered while it was closed moves the most recent inbound - channel, as an open channel's `channels/incoming` does. That holds across a restart, until - the next decision about the channel (an open, a close, a decline or a tune-out). A push from - any other closed channel moves nothing, as before. + hints, doesn't see them. Where a turn with no trigger (a heartbeat) speaks still follows the + agent: a push from a channel it answered while the channel was closed moves the most recent + inbound channel, as an open channel's `channels/incoming` does. That holds across a restart, + until the next decision about the channel (an open, a close, a decline or a tune-out). For a + DM, every message of which reaches the agent, that is as before. An answered guild channel's + messages that don't address the agent no longer arrive, so only those that do move it. A + push from a closed channel the agent never answered moves nothing, as before. - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s description now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel @@ -39,9 +41,12 @@ after a restart, and now stays closed until the agent opens it. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went - to, from a composite or raw id, and opens nothing. `noteAnswered(channelId, serverId?)` and - `notePushDelivered(serverId, channelId, messageId?, threadId?, tags?)` are new: the framework - calls them when the agent answers a closed channel and when a push is accepted. + to, from a composite or raw id, and opens nothing. `noteAnswered(channelId, serverId)` and + `notePushDelivered(serverId, channelId, messageId?, threadId?, tags?)` are new: `routeSpeech` + and the framework's send path call the first when the agent answers a closed channel, and the + framework's push lane calls the second when it accepts a push. A module's `channel_close` with + an unknown machine source is now refused even when the channel is already closed. The + coalescer's host can observe each occurrence it admits afresh (`accepted`). `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. A `desired-state` record in the channel lifecycle log can carry `answered: true`; diff --git a/changelog.d/speaking-never-opens.fixed.md b/changelog.d/speaking-never-opens.fixed.md index 1262d1e1e..ab31c1095 100644 --- a/changelog.d/speaking-never-opens.fixed.md +++ b/changelog.d/speaking-never-opens.fixed.md @@ -5,5 +5,5 @@ - The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which is closed by default, is now recorded as its decision. Before, the channel kept its default, so subscription policy or a server's `initiallyOpen` could open it later. A machine close of - such a channel, such as connectome-host's `subscription-gc`, still records nothing, so policy - can still open it. + such a channel, such as connectome-host's `subscription-gc`, still records no decision: the + channel keeps its default, so policy can still open it. diff --git a/src/framework.ts b/src/framework.ts index 40a593991..286ebac63 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9595,6 +9595,14 @@ export class AgentFramework { this.coalescingRecentReceipts.push(record); this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); }, + // A push admitted afresh, deferred included, is where a message came + // in. This hook never runs for a producer's retry, which the coalescer + // acknowledges again without delivering anything; a withdrawal never + // counts, even when its notice is shown. + accepted: (occ) => { + if (occ.event.lane !== 'push' || occ.retract) return; + this.notePushAccepted(occ.event.event, occ.scope.kind === 'channel' ? occ.scope.id : undefined); + }, wasPublished: (subject, eventId) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. @@ -9729,11 +9737,7 @@ export class AgentFramework { if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId); const origin = params.origin ?? {}; const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); - // Admitted afresh, as markAccepted counts: a withdrawal, or a producer's - // retry of work already acknowledged, is never where the last message - // came in. - const fresh = !c.retract && !this.pushCoalescer.receipt(serverId, this.coalescingBinding(serverId), params.eventId); - const result = await this.pushCoalescer.accept({ + return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), featureSet: params.featureSet, @@ -9756,8 +9760,6 @@ export class AgentFramework { } : {}), event: { lane: 'push', event }, }); - if (fresh) this.notePushAccepted(event, c.channelId); - return result; }); } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 360f54351..7179251c7 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1981,34 +1981,42 @@ export class ChannelRegistry { * moves the most recent inbound channel (notePushDelivered), as an open * channel's channels/incoming does. Kept on the desired-state record, so it * survives a restart, until the next decision about the channel: an open, - * a close, a decline or a tune-out. + * a close, a decline or a tune-out. It runs after a delivered publish, so a + * store that fails to record it is logged, not thrown: the publish stands, + * and the mark holds until this process ends. */ - noteAnswered(channelId: string, serverId?: string): void { - const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); + noteAnswered(channelId: string, serverId: string): void { + const entry = this.channels.get(`${serverId}:${channelId}`); if (!entry || entry.open) return; - const key = this.lifecycleKey(entry.serverId, entry.descriptor.id); + const key = this.lifecycleKey(serverId, channelId); const current = this.desiredStates.get(key); if (current?.answered || current?.state === 'tuned-out') return; const state = current?.state ?? 'closed'; const source = current?.source ?? 'default-closed'; this.desiredStates.set(key, { ...current, state, source, answered: true }); - this.appendLifecycleEvent({ - kind: 'desired-state', - serverId: entry.serverId, - channelId: entry.descriptor.id, - desired: state, - source, - answered: true, - timestamp: new Date().toISOString(), - }); + try { + this.appendLifecycleEvent({ + kind: 'desired-state', + serverId, + channelId, + desired: state, + source, + answered: true, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error(`[channel] answered mark for ${channelId} not recorded: ${(error as Error).message}`); + } } /** * A push was accepted from this channel. One from a channel the agent - * answered while it was closed (noteAnswered) is where the last message - * came in, as an accepted channels/incoming message is (markAccepted), so - * a turn with no trigger speaks there. A push from any other channel moves - * nothing, as before, and neither does a reaction or a deletion. + * answered while it was closed (noteAnswered), and that is closed still, is + * where the last message came in, as an accepted channels/incoming message + * is (markAccepted), so a turn with no trigger speaks there. Once the + * channel's transport is open, its messages arrive as channels/incoming and + * move it there. A push from any other channel moves nothing, as before, + * and neither does a reaction or a deletion. */ notePushDelivered( serverId: string, @@ -2018,6 +2026,7 @@ export class ChannelRegistry { tags?: readonly string[], ): void { if (!this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.answered) return; + if (this.channels.get(`${serverId}:${channelId}`)?.open) return; if (tags?.some((t) => t === 'chat:reaction' || t === 'chat:reaction-remove' || t === 'chat:deleted')) return; this.defaultPublishChannel = channelId; this.defaultPublishMessageId = messageId ?? null; diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 7615256b1..a134a41d2 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -205,6 +205,10 @@ export interface CoalescerHost { /** Persist one receipt now, before the acceptance is acknowledged. Throws * on failure, which fails the acceptance (the producer retries). */ recordReceipt?(record: CoalescingReceiptRecord): void; + /** One call per ADMITTED occurrence (never for a producer retry the receipt + * deduplicates), after its receipt is recorded. Observation only: a throw + * is contained here and never fails the acceptance. */ + accepted?(occurrence: CoalescedOccurrence): void; /** Make every write so far DURABLE (fsync, group-committed by the host). * Awaited before an acceptance is acknowledged and before push/render is * issued: a kill after either boundary must find the receipt, the @@ -485,6 +489,11 @@ export class PushCoalescer { throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000); } this.receipts.set(receiptKey, entry); + try { + this.host.accepted?.(occurrence); + } catch (error) { + this.host.audit({ kind: 'accepted-observer-failed', subject, eventId: occurrence.eventId, error: String(error) }); + } this.prune(); this.persist(); // Written but not yet durable: the in-memory receipt deduplicates a retry diff --git a/test/answered-channel-fallback.test.ts b/test/answered-channel-fallback.test.ts index 210a7f469..5c6160e34 100644 --- a/test/answered-channel-fallback.test.ts +++ b/test/answered-channel-fallback.test.ts @@ -109,6 +109,14 @@ test("a coalesced push counts once, when it's admitted afresh, and a withdrawal await f.send('push/event', first); assert.equal(registry.getDefaultPublishChannel(), 'lobby', "a retry of acknowledged work isn't a new message"); + // A push scoped to its feature set, whose channel comes from its origin, as + // discord-mcpl sends one for a channel it hasn't declared. + await f.send('push/event', { ...push('c3', 'dm', ['chat:dm', 'chat:addressed']), coalesce: { key: 'message:m-c3', initial: true } }); + assert.equal(registry.getDefaultPublishChannel(), 'dm', 'a feature-set-scoped push counts by its origin'); + await f.send('channels/incoming', { + messages: [{ channelId: 'lobby', messageId: 'l3', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'and again' }] }], + }); + // A withdrawal of the DM's message, tagged as a message so only the // withdrawal itself can keep it from counting. const withdrawal = { ...push('c2', 'dm', ['chat:dm', 'chat:addressed']), coalesce: { channelId: 'dm', key: 'message:m-c1', retract: true }, payload: { content: [] } }; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 3e235fc26..c42ffb10b 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -397,6 +397,73 @@ test("the agent's close or decline of a channel nobody decided about sticks agai assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['closed-by-gc'], 'policy opens only that one'); }); +test('only a delivered answer into a closed channel marks it, and only its conversational pushes count while it stays closed', async () => { + const conversational = ['chat:dm', 'chat:addressed']; + // A refused publish answers nothing. + const refused = makeRegistry({ delivered: false }); + seedRegistered(refused.registry, 'discord', 'dm'); + assert.equal(await refused.registry.routeSpeech('sol', 'not delivered', 'dm'), null); + refused.registry.notePushDelivered('discord', 'dm', 'm1', undefined, conversational); + assert.equal(refused.registry.getDefaultPublishChannel(), null, 'a refused publish answers nothing'); + + const { registry } = makeRegistry({ delivered: true }); + seedRegistered(registry, 'discord', 'dm', 'lobby'); + await registry.routeSpeech('sol', 'answered', 'dm'); + registry.handleIncoming('discord', incoming('lobby', 'hi')); + for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:deleted']) { + registry.notePushDelivered('discord', 'dm', 'm2', undefined, [tag]); + assert.equal(registry.getDefaultPublishChannel(), 'lobby', `${tag} isn't a message coming in`); + } + registry.notePushDelivered('discord', 'dm', 'm3', undefined, conversational); + assert.equal(registry.getDefaultPublishChannel(), 'dm'); + + // Its transport opens (the server delivers channels/incoming from it): its + // messages move the fallback that way now, and a push, such as an edit, doesn't. + registry.handleIncoming('discord', incoming('dm', 'over incoming')); + registry.handleIncoming('discord', incoming('lobby', 'hi again')); + registry.notePushDelivered('discord', 'dm', 'm4', undefined, ['chat:edited']); + assert.equal(registry.getDefaultPublishChannel(), 'lobby'); +}); + +test('the next decision about an answered channel ends its answer', async () => { + const origin = { kind: 'agent' as const, agentName: 'sol' }; + const dm = { added: [{ id: 'dm', type: 'discord', label: 'DM: antra', direction: 'bidirectional' }] } as never; + const cases: Array<[string, (r: ChannelRegistry, lookup: (id: string) => { open: boolean } | undefined) => Promise]> = [ + ['channel_open', async (r, lookup) => { + await r.handleChannelToolCall('channel_open', { channelId: 'dm' }, origin); + lookup('dm')!.open = false; // and its transport drops again later + }], + ['channel_decline', (r) => r.handleChannelToolCall('channel_decline', { channelId: 'dm', messageId: 'm0' }, origin)], + ['a tune-out', async (r) => r.enterTuneOut('discord', 'dm', { + epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, + }, 'agent-tool')], + ['a machine close', (r) => r.handleChannelToolCall( + 'channel_close', { channelId: 'dm', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' })], + // The agent had closed it before it answered, so this close keeps the + // same decision on record and still ends the answer. + ["the agent's close, again", (r) => r.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin)], + ]; + for (const [name, decide] of cases) { + const { registry, lookup } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', dm); + seedRegistered(registry, 'discord', 'lobby'); + if (name === "the agent's close, again") { + await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin); + } + await registry.routeSpeech('sol', 'answered', 'dm'); + await decide(registry, lookup); + registry.handleIncoming('discord', incoming('lobby', 'elsewhere')); + registry.notePushDelivered('discord', 'dm', 'm1', undefined, ['chat:dm', 'chat:addressed']); + assert.equal(registry.getDefaultPublishChannel(), 'lobby', `${name} ends the answer`); + if (name === 'a machine close') { + assert.equal(registry.getDesiredState('discord', 'dm'), 'closed'); + registry.setSubscriptionPolicy('discord', 'auto'); + await registry.handleChanged('discord', dm); + assert.equal(registry.getDesiredState('discord', 'dm'), 'open', 'and decides nothing: policy may still open it'); + } + } +}); + test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = (id: string, raw: string) => ({ @@ -405,7 +472,7 @@ test('resolveSendTarget names the channel a send went to and guesses nothing', a await registry.handleChanged('discord-a', { added: [added('discord:g:444', '444'), added('discord:g:555', '555')] } as never); await registry.handleChanged('discord-b', { added: [added('discord:h:555', '555')] } as never); const opensBefore = openCalls.length; - // Awaited, so main's openIfClosedForSend can stand in for it in a red-first run. + // Awaited, so the test holds whether the lookup answers at once or later. assert.equal(await registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); assert.equal(await registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index e7b34c4fa..640a2f609 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -115,7 +115,8 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco assert.deepEqual(engagedAtResult, [channelId], 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); - // The channel's next message, by push, is where a turn with no trigger speaks. + // The send marked the channel answered: when the push lane reports its + // next message, a turn with no trigger speaks there. assert.equal(registry.getDefaultPublishChannel(), null); registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); assert.equal(registry.getDefaultPublishChannel(), channelId, 'the send answered the channel'); From 46a1e8653f760ef1e6f4815c09c35de285b66710 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Wed, 14 Oct 2026 05:40:35 -0400 Subject: [PATCH 07/37] fix(mcpl): an edit from an answered channel isn't a new message discord-mcpl pushes every edit, tagged chat:edited, whether its channel is open or closed, so on main an edit never moved the most recent inbound channel: only channels/incoming did. notePushDelivered skipped reactions, their removals and deletions, but counted an edit, so an edit to an old message in an answered DM sent the next heartbeat there. The four tags that mark something other than a new message are now one set, NOT_A_NEW_MESSAGE, and the registry test checks each; the check on an answered channel whose transport opened now uses a conversational push, since an edit no longer counts anyway. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/channel-registry.ts | 13 +++++++++++-- test/channel-registry-routing.test.ts | 6 +++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 7179251c7..3b3a55f29 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -562,6 +562,14 @@ export type ChannelToolOrigin = | { kind: 'module'; moduleName?: string } | { kind: 'agent'; agentName: string }; +/** Push tags that mark something other than a new message: a reaction, its + * removal, an edit or a deletion. discord-mcpl sends these as pushes from an + * open channel too, so on their own they never moved the most recent inbound + * channel (notePushDelivered). */ +const NOT_A_NEW_MESSAGE: ReadonlySet = new Set([ + 'chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted', +]); + /** The machine decision sources a module-origin channel_close may record. * Closed on purpose: honest provenance means a small, auditable vocabulary, * not arbitrary self-description. */ @@ -2016,7 +2024,8 @@ export class ChannelRegistry { * is (markAccepted), so a turn with no trigger speaks there. Once the * channel's transport is open, its messages arrive as channels/incoming and * move it there. A push from any other channel moves nothing, as before, - * and neither does a reaction or a deletion. + * and neither does a reaction, an edit or a deletion: those arrive as pushes + * from an open channel too, and never moved it. */ notePushDelivered( serverId: string, @@ -2027,7 +2036,7 @@ export class ChannelRegistry { ): void { if (!this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.answered) return; if (this.channels.get(`${serverId}:${channelId}`)?.open) return; - if (tags?.some((t) => t === 'chat:reaction' || t === 'chat:reaction-remove' || t === 'chat:deleted')) return; + if (tags?.some((t) => NOT_A_NEW_MESSAGE.has(t))) return; this.defaultPublishChannel = channelId; this.defaultPublishMessageId = messageId ?? null; this.defaultPublishThreadId = threadId; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index c42ffb10b..808396d0c 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -410,7 +410,7 @@ test('only a delivered answer into a closed channel marks it, and only its conve seedRegistered(registry, 'discord', 'dm', 'lobby'); await registry.routeSpeech('sol', 'answered', 'dm'); registry.handleIncoming('discord', incoming('lobby', 'hi')); - for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:deleted']) { + for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { registry.notePushDelivered('discord', 'dm', 'm2', undefined, [tag]); assert.equal(registry.getDefaultPublishChannel(), 'lobby', `${tag} isn't a message coming in`); } @@ -418,10 +418,10 @@ test('only a delivered answer into a closed channel marks it, and only its conve assert.equal(registry.getDefaultPublishChannel(), 'dm'); // Its transport opens (the server delivers channels/incoming from it): its - // messages move the fallback that way now, and a push, such as an edit, doesn't. + // messages move the fallback that way now, and a push doesn't. registry.handleIncoming('discord', incoming('dm', 'over incoming')); registry.handleIncoming('discord', incoming('lobby', 'hi again')); - registry.notePushDelivered('discord', 'dm', 'm4', undefined, ['chat:edited']); + registry.notePushDelivered('discord', 'dm', 'm4', undefined, conversational); assert.equal(registry.getDefaultPublishChannel(), 'lobby'); }); From 6d0cdf6da4bca2440d765920975f7ec06669af3d Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Wed, 14 Oct 2026 05:43:22 -0400 Subject: [PATCH 08/37] docs(changelog): a message pushed from an answered channel moves the fallback As the code counts it: reactions, edits and deletions don't, so the fragment says a message pushed from the channel rather than a push. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 77229fe71..4fc86cc9d 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -16,12 +16,12 @@ the agent arrive as push events, as any closed channel's do, instead of `channels/incoming`. Host code that acts on `channels/incoming`, such as connectome-host's outage reply and typing hints, doesn't see them. Where a turn with no trigger (a heartbeat) speaks still follows the - agent: a push from a channel it answered while the channel was closed moves the most recent - inbound channel, as an open channel's `channels/incoming` does. That holds across a restart, - until the next decision about the channel (an open, a close, a decline or a tune-out). For a - DM, every message of which reaches the agent, that is as before. An answered guild channel's - messages that don't address the agent no longer arrive, so only those that do move it. A - push from a closed channel the agent never answered moves nothing, as before. + agent: a message pushed from a channel it answered while the channel was closed moves the + most recent inbound channel, as an open channel's `channels/incoming` does. That holds across + a restart, until the next decision about the channel (an open, a close, a decline or a + tune-out). For a DM, every message of which reaches the agent, that is as before. An answered + guild channel's messages that don't address the agent no longer arrive, so only those that do + move it. A push from a closed channel the agent never answered moves nothing, as before. - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s description now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel From 252a4055546ecbfb029a40d3ebb453b92db3c005 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Wed, 14 Oct 2026 05:51:24 -0400 Subject: [PATCH 09/37] test(mcpl): speech into a closed channel logs only its routed line; the mark's failure line prints one field On main, test/mcpl-rejection-log-lines.test.ts pins the two [routeSpeech] lines that speech into a closed channel printed: the open by delivery, then the routed line. This change removed the open, so only the routed line prints. The test now expects that, and gives the registry a store that can't record the channel's answered mark, so the new line for that failure prints too, with the channel as one field (logValue) and the store's message as the line's tail (logDetail), the rule that test file pins for every value a server sends. It also shows the publish standing when the mark isn't recorded. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/channel-registry.ts | 2 +- test/mcpl-rejection-log-lines.test.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 3b3a55f29..1f619fa67 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2013,7 +2013,7 @@ export class ChannelRegistry { timestamp: new Date().toISOString(), }); } catch (error) { - console.error(`[channel] answered mark for ${channelId} not recorded: ${(error as Error).message}`); + console.error(`[channel] answered mark for ${logValue(channelId)} not recorded: ${logDetail((error as Error).message)}`); } } diff --git a/test/mcpl-rejection-log-lines.test.ts b/test/mcpl-rejection-log-lines.test.ts index 31e94cede..d31c55628 100644 --- a/test/mcpl-rejection-log-lines.test.ts +++ b/test/mcpl-rejection-log-lines.test.ts @@ -244,16 +244,22 @@ test('speech routed into a closed channel names the channel as one field on each sendChannelsPublish: async () => ({ delivered: true }), sendChannelsOpen: async () => ({}), }; + // A store that can't record the channel's answered mark, so its line prints too. + const store = { + registerState: () => {}, + getStateJson: () => null, + appendToStateJson: () => { throw new Error('disk full\nreason=forged'); }, + }; const registry = new ChannelRegistry( - { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, {}, + { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, { store: store as never }, ); (registry as unknown as { channels: Map }).channels.set(`editor:${FORGED_CHANNEL}`, { serverId: 'editor', descriptor: { id: FORGED_CHANNEL, type: 'discord', label: 'chat' }, open: false, }); const routed = await registry.routeSpeech('agent', 'hello', FORGED_CHANNEL); - assert.equal(routed?.delivered, true); - assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]')), [ - `[routeSpeech] agent: locus ${field(FORGED_CHANNEL)} was closed — opened by delivery (speech implies engagement)`, + assert.equal(routed?.delivered, true, 'the publish stands when the mark is not recorded'); + assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]') || l.startsWith('[channel]')), [ + `[channel] answered mark for ${field(FORGED_CHANNEL)} not recorded: ${logDetail('disk full\nreason=forged')}`, `[routeSpeech] agent: routed 5 chars -> ${field(FORGED_CHANNEL)} (server=editor, delivered=true)`, ]); }); From 102e1d2983b1f02fe7cad2353c4cd8ee4a0c9e44 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Wed, 14 Oct 2026 06:45:20 -0400 Subject: [PATCH 10/37] fix(mcpl): an answer still in flight when its channel is decided marks nothing Greptile on #330: calls in one tool round run in parallel, so a send can post while its channel is open, the agent's channel_close can finish, and the send's late result then marked the now-closed channel answered, so the close no longer ended where a turn with no trigger speaks. routeSpeech had the same race across its publish. The registry now counts its decisions about channels' desired state, each call that decides counting, one that keeps the record as it was included (setDesiredState, a tune-out's entry and end), and keeps each channel's latest. routeSpeech reads the count before it publishes, and the framework before it dispatches a send tool's call (decisionSequence); noteAnswered takes it as decidedSince and marks nothing when a decision about the channel came after it. Tests: a registry test holds routeSpeech's publish while the agent closes the channel, once with no decision on record and once over the agent's earlier close, where closing again keeps the record; a send path test holds reply_message's result while the agent closes the channel it had open. Both check a later push moves nothing. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speaking-never-opens.breaking.md | 20 +++++++------ src/framework.ts | 5 +++- src/mcpl/channel-registry.ts | 30 ++++++++++++++++---- test/channel-registry-routing.test.ts | 21 ++++++++++++++ test/send-into-closed-channel.test.ts | 20 +++++++++++++ 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 4fc86cc9d..2f5df178d 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -19,9 +19,11 @@ agent: a message pushed from a channel it answered while the channel was closed moves the most recent inbound channel, as an open channel's `channels/incoming` does. That holds across a restart, until the next decision about the channel (an open, a close, a decline or a - tune-out). For a DM, every message of which reaches the agent, that is as before. An answered - guild channel's messages that don't address the agent no longer arrive, so only those that do - move it. A push from a closed channel the agent never answered moves nothing, as before. + tune-out). A decision made while the answer is still in flight, such as a close in the same + round, stands too: the answer marks nothing. For a DM, every message of which reaches the + agent, that is as before. An answered guild channel's messages that don't address the agent no + longer arrive, so only those that do move it. A push from a closed channel the agent never + answered moves nothing, as before. - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s description now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel @@ -41,12 +43,14 @@ after a restart, and now stays closed until the agent opens it. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went - to, from a composite or raw id, and opens nothing. `noteAnswered(channelId, serverId)` and + to, from a composite or raw id, and opens nothing. `decisionSequence()`, + `noteAnswered(channelId, serverId, decidedSince)` and `notePushDelivered(serverId, channelId, messageId?, threadId?, tags?)` are new: `routeSpeech` - and the framework's send path call the first when the agent answers a closed channel, and the - framework's push lane calls the second when it accepts a push. A module's `channel_close` with - an unknown machine source is now refused even when the channel is already closed. The - coalescer's host can observe each occurrence it admits afresh (`accepted`). + and the framework's send path read the first before they publish and call the second when the + agent answers a closed channel, and the framework's push lane calls the third when it accepts + a push. A module's `channel_close` with an unknown machine source is now refused even when the + channel is already closed. The coalescer's host can observe each occurrence it admits afresh + (`accepted`). `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. A `desired-state` record in the channel lifecycle log can carry `answered: true`; diff --git a/src/framework.ts b/src/framework.ts index 286ebac63..2dc08a839 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -17646,6 +17646,9 @@ export class AgentFramework { } } + // Decisions about channels taken while the call is in flight, such as the + // agent's channel_close in the same round, outrank the answer it makes. + const decidedSince = this.channelRegistry?.decisionSequence() ?? 0; server.sendToolsCall(toolName, args, stateParams) .then(async (result) => { // RFC-005: register references and eagerly fetch the small ones @@ -17695,7 +17698,7 @@ export class AgentFramework { engaged.add(engagedChannel); // A closed channel stays closed, and where a turn with no trigger // speaks still follows the agent there. - this.channelRegistry.noteAnswered(engagedChannel, serverId); + this.channelRegistry.noteAnswered(engagedChannel, serverId, decidedSince); } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 1f619fa67..68ebb2360 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -688,6 +688,12 @@ export class ChannelRegistry { * and nothing has decided about it since. */ answered?: true; }>(); + /** Decisions about channels' desired state so far, and each channel's + * latest, so an answer still in flight when a decision about its channel + * is made marks nothing (noteAnswered's `decidedSince`). Every call that + * decides counts, one that keeps the record as it was included. */ + private decisionCount = 0; + private lastDecisionAt = new Map(); /** One-time migration inputs from the retired recipe auto-open policy. */ private legacyPolicies = new Map(); @@ -1957,6 +1963,7 @@ export class ChannelRegistry { source: string, ): void { const key = this.lifecycleKey(serverId, channelId); + this.lastDecisionAt.set(key, ++this.decisionCount); const current = this.desiredStates.get(key); let recorded = source; if (current?.state === desired) { @@ -1981,6 +1988,12 @@ export class ChannelRegistry { }); } + /** How many decisions about channels' desired state the registry has + * taken: read before a publish or a send, and passed to noteAnswered after. */ + decisionSequence(): number { + return this.decisionCount; + } + /** * The agent's speech, or a send tool's call, delivered into this channel * while it was closed. Speaking never opens a channel, but where the @@ -1989,14 +2002,18 @@ export class ChannelRegistry { * moves the most recent inbound channel (notePushDelivered), as an open * channel's channels/incoming does. Kept on the desired-state record, so it * survives a restart, until the next decision about the channel: an open, - * a close, a decline or a tune-out. It runs after a delivered publish, so a - * store that fails to record it is logged, not thrown: the publish stands, - * and the mark holds until this process ends. + * a close, a decline or a tune-out. A decision made while the answer was + * in flight, after `decidedSince` (decisionSequence() taken before it was + * sent), stands too: the answer marks nothing, as when the agent closes a + * channel in the same round it answers there. It runs after a delivered + * publish, so a store that fails to record it is logged, not thrown: the + * publish stands, and the mark holds until this process ends. */ - noteAnswered(channelId: string, serverId: string): void { + noteAnswered(channelId: string, serverId: string, decidedSince: number): void { const entry = this.channels.get(`${serverId}:${channelId}`); if (!entry || entry.open) return; const key = this.lifecycleKey(serverId, channelId); + if ((this.lastDecisionAt.get(key) ?? 0) > decidedSince) return; const current = this.desiredStates.get(key); if (current?.answered || current?.state === 'tuned-out') return; const state = current?.state ?? 'closed'; @@ -2059,6 +2076,7 @@ export class ChannelRegistry { source: string, ): void { const key = this.lifecycleKey(serverId, channelId); + this.lastDecisionAt.set(key, ++this.decisionCount); this.desiredStates.set(key, { state: 'tuned-out', source, @@ -2092,6 +2110,7 @@ export class ChannelRegistry { const current = this.desiredStates.get(key); if (current?.state !== 'tuned-out' || !current.tuneOut) return null; const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 }; + this.lastDecisionAt.set(key, ++this.decisionCount); this.desiredStates.set(key, { state: nextState, source }); this.appendLifecycleEvent({ kind: 'desired-state', @@ -3189,6 +3208,7 @@ export class ChannelRegistry { channelId, content: [{ type: 'text', text }], }; + const decidedSince = this.decisionCount; const result = await server.sendChannelsPublish(publishParams); const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; // Surface the posted message's id (ChannelsPublishResult.messageId) so @@ -3205,7 +3225,7 @@ export class ChannelRegistry { } // Answering a closed channel leaves it closed, and where a turn with no // trigger speaks still follows the agent there (noteAnswered). - if (!entry.open) this.noteAnswered(channelId, entry.serverId); + if (!entry.open) this.noteAnswered(channelId, entry.serverId, decidedSince); console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`); this.emitTraceFn({ diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 808396d0c..d40116c1e 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -464,6 +464,27 @@ test('the next decision about an answered channel ends its answer', async () => } }); +test("an answer still in flight when the agent decides about its channel marks nothing", async () => { + const origin = { kind: 'agent' as const, agentName: 'sol' }; + // Once with no decision on record, and once over the agent's own earlier + // close, where closing again keeps the record as it was. + for (const closedBefore of [false, true]) { + let release!: () => void; + const held = new Promise<{ delivered: boolean }>((resolve) => { release = () => resolve({ delivered: true }); }); + const { registry } = makeRegistry(held as unknown as { delivered: boolean }); + seedRegistered(registry, 'discord', 'dm', 'lobby'); + if (closedBefore) await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin); + const speaking = registry.routeSpeech('sol', 'answering', 'dm'); + // The same round closes the channel while the publish is in flight. + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin)).success, true); + release(); + assert.ok(await speaking, 'the answer is still delivered'); + registry.handleIncoming('discord', incoming('lobby', 'elsewhere')); + registry.notePushDelivered('discord', 'dm', 'm2', undefined, ['chat:dm', 'chat:addressed']); + assert.equal(registry.getDefaultPublishChannel(), 'lobby', `the close stands (closed before: ${closedBefore})`); + } +}); + test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = (id: string, raw: string) => ({ diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 640a2f609..378e2f1b7 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -26,10 +26,13 @@ async function harness() { // Every channels/open attempt, including one the server refuses. const opens: Array<{ channelId?: string }> = []; let refuseOpens = false; + // While set, the send's result waits for it. + let hold: Promise | null = null; const server = { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), async sendToolsCall() { + if (hold) await hold; // discord-mcpl's send tools answer with the posted message and the raw channel id. return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: '444555' }) }] }; }, @@ -95,6 +98,7 @@ async function harness() { return { registry, opens, windowMessages, reply, setRefuseOpens: (v: boolean) => { refuseOpens = v; }, + setHold: (p: Promise | null) => { hold = p; }, }; } @@ -155,3 +159,19 @@ test('a reply_message into a tuned-out channel leaves the tune-out in place', as assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); }); + +test('a reply_message still in flight when the agent closes its channel marks nothing', async () => { + const { registry, reply, setHold } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + let release!: () => void; + setHold(new Promise((resolve) => { release = resolve; })); + const replying = reply(); + // The same round closes the channel while the reply is in flight. + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + release(); + const { success } = await replying; + assert.equal(success, true); + assert.equal(registry.isChannelOpen(channelId), false, 'the channel stays closed'); + registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); + assert.equal(registry.getDefaultPublishChannel(), null, 'the close, made while the reply was in flight, stands'); +}); From 89dd16dd7388de9304cebd1f7f265eca518ddddf Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Thu, 15 Oct 2026 19:14:46 -0400 Subject: [PATCH 11/37] feat(channels): a note where an agent's words stay in a closed channel, which they can turn off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nissa, reading #330's rule-8 ask (personal:oscar-1808 #108565): it needs maintainer review "especially since the change could disorient residents", and changes that need a habit adjusted could carry an informative note when the old pattern is used, which the resident can suppress for good. Ada-1017 settled the design (room-203 #108583, #108612), with three changes of mine (#108609): the wording says what still arrives, DMs are skipped, and the note comes at the act itself. Where an agent's words land in a closed channel that isn't a DM: - a send tool's answer gets one more text block after the server's own: "[closed channel] #upkeep () stays closed: speaking into a closed channel doesn't open it. Messages there that address you, such as an @-mention, still reach you; to hear the rest of its traffic, call channel_open with channelId "" and serverId "". To stop this note, set closed_channel_note to false with agent_settings." - routed speech's turn-end receipt, "[delivered] plain speech → …", carries the same words after the channels it names, the stop sentence once. It's said as a standing fact, so it's true for a resident who never knew the old behaviour, and it claims nothing about replies: on discord-mcpl a reply to the agent's message addresses it and arrives in a closed channel. A DM is left out, since every DM message reaches the agent open or not: a send by send_dm; a channel that has delivered a chat:dm message in this process, recorded on both ingest paths where the invitation reads the same tag; or one the registry knows as a DM by metadata or id and label shape (ChannelRegistry.isDirectMessageChannel, public over isDmChannelId). closed_channel_note is a framework-owned agent_settings extension, a boolean on by default, kept per agent in framework state like tool_result_guard. The setting is read only when there's a note to give, and working the note out is contained: if it fails, the delivered send or speech stands, the failure is logged, and nothing is added. Tests: send-into-closed-channel (8): the note on a send into a closed channel, its exact words after the server's answer, turned off and reset through the extension and kept in framework state; none for an open channel or a DM by each of the three signs; the receipt's note, none for a DM, an open channel or once turned off; a store that can't be read leaves the answer as the server gave it. trunk-channel-routing: both ingest paths record a chat:dm channel, and only it. present-while-acting's stub registry reports its channel open, so its send exercises no contained failure. The breaking fragment says what residents read and the new setting, the registry's new method, and that agent_settings' schema changes the cache. --- changelog.d/speaking-never-opens.breaking.md | 14 +- src/framework.ts | 110 ++++++++++++++- src/mcpl/channel-registry.ts | 6 + test/present-while-acting.test.ts | 2 + test/send-into-closed-channel.test.ts | 133 ++++++++++++++++++- test/trunk-channel-routing.test.ts | 5 + 6 files changed, 259 insertions(+), 11 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index 2f5df178d..a0c7e6e0c 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -7,6 +7,12 @@ - **Who needs to act:** an agent that wants a channel's ongoing traffic after speaking there opens it with `channel_open`; speaking no longer does. Messages that address the agent still arrive while a channel is closed. + - **What residents read:** where an agent's words land in a closed channel that isn't a DM, the + send tool's answer, or for routed speech the turn's `[delivered]` line, says that the channel + stays closed, that messages addressing the agent still reach it, and how to open it with + `channel_open`. The agent turns that note off for good with `agent_settings`' new + `closed_channel_note`, which is on by default. A channel counts as a DM when the send was + `send_dm`, when it has delivered a `chat:dm` message, or when the registry knows it as one. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a closed guild channel used to open it, and so turn its reactions on. Now reactions to the reply arrive only where the agent @@ -54,6 +60,8 @@ `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer emitted. A `desired-state` record in the channel lifecycle log can carry `answered: true`; - earlier versions ignore the field. -- **Prompt cache:** `channel_close`'s description changes, so the first request after the - upgrade that carries the channel tools misses the cache from its tool block on. + earlier versions ignore the field. `isDirectMessageChannel(channelId)` is new, and says whether + the registry knows a channel as a DM. +- **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so + the first request after the upgrade that carries those tools misses the cache from its tool + block on. diff --git a/src/framework.ts b/src/framework.ts index d7938b953..7dd9333d2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3892,6 +3892,11 @@ export class AgentFramework { ext.keys.forEach((key) => taken.add(key)); result.set('_toolResultGuard', ext); } + { + const ext = this.closedChannelNoteSettingsExtension(); + ext.keys.forEach((key) => taken.add(key)); + result.set('_closedChannelNote', ext); + } for (const module of this.moduleRegistry.getAllModules()) { const ext = module.getAgentSettingsExtension?.(); if (!ext) continue; @@ -4002,6 +4007,92 @@ export class AgentFramework { }; } + /** Whether the agent wants the note on words that land in a closed channel: + * on unless they've turned it off with agent_settings. */ + private closedChannelNoteOn(agentName: string): boolean { + const state = this.store.getStateJson(FRAMEWORK_STATE_ID) as { + closedChannelNotes?: Record; + } | null; + return state?.closedChannelNotes?.[agentName] !== false; + } + + private closedChannelNoteSettingsExtension(): AgentSettingsExtension { + const get = (name: string): Record => ({ closed_channel_note: this.closedChannelNoteOn(name) }); + const set = (name: string, on: boolean | undefined): Record => { + if (!this.agents.has(name)) throw new Error(`Unknown agent: ${name}`); + const data = this.store.getStateJson(FRAMEWORK_STATE_ID); + const state = (data && typeof data === 'object' ? data : {}) as Record; + const values = { ...((state.closedChannelNotes as Record | undefined) ?? {}) }; + if (on === undefined) delete values[name]; + else values[name] = on; + state.closedChannelNotes = values; + this.store.setStateJson(FRAMEWORK_STATE_ID, state); + return get(name); + }; + return { + properties: { + closed_channel_note: { + type: 'boolean', + description: 'Add a note when your words go to a closed channel, saying that it stays closed, what still ' + + 'reaches you from it, and how to open it. On by default. Persists across turns and restarts until changed.', + }, + }, + keys: ['closed_channel_note'], + get, + update: (name, patch) => { + if (typeof patch.closed_channel_note !== 'boolean') throw new Error('closed_channel_note must be a boolean'); + return set(name, patch.closed_channel_note); + }, + reset: (name) => set(name, undefined), + }; + } + + /** Channels that have delivered a message tagged chat:dm in this process: + * every message of a DM reaches the agent, open or not, so the closed-channel + * note is left off there. */ + private readonly directMessageChannels = new Set(); + + private noteDirectMessageTags(channelId: string, tags: string[] | undefined): void { + if (tags?.includes('chat:dm')) this.directMessageChannels.add(channelId); + } + + /** What an agent is told when their words go to a channel that stays closed, + * or undefined where nothing needs saying: an open channel, or a DM, known by + * a chat:dm message it delivered or by what the registry knows of it. */ + private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { + const registry = this.channelRegistry; + if (!registry || registry.isChannelOpen(channelId)) return undefined; + if (this.directMessageChannels.has(channelId) || registry.isDirectMessageChannel(channelId)) return undefined; + const label = registry.getDescriptor(channelId)?.label; + const place = label && label !== channelId + ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` + : channelId; + const open = serverId + ? `call channel_open with channelId "${channelId}" and serverId "${serverId}"` + : `call channel_open with channelId "${channelId}"`; + return `${place} stays closed: speaking into a closed channel doesn't open it. Messages there that address you, ` + + `such as an @-mention, still reach you; to hear the rest of its traffic, ${open}.`; + } + + /** How to stop the closed-channel note, said once wherever it appears. */ + private static readonly CLOSED_CHANNEL_NOTE_OFF = 'To stop this note, set closed_channel_note to false with agent_settings.'; + + /** The closed-channel notes for an agent's words that landed in these + * channels, or none when they've turned the note off. The note is advice: + * if working it out fails, the words were still delivered, so that's + * logged and nothing is added. */ + private closedChannelNotes(agentName: string, channelIds: Iterable, serverId?: string): string[] { + try { + const notes = [...channelIds].flatMap((id) => + this.closedChannelNoteFor(id, serverId ?? this.channelRegistry?.getChannelServerId(id) ?? null) ?? []); + // The setting is read only when there's a note to give. + return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : []; + } catch (err) { + console.error(`[closed-channel-note] for ${agentName} failed: ${(err as Error).message}`); + return []; + } + } + /** Every guard's annotation still running, for stop() (agent-framework #277). */ private readonly guardAnnotations = new Set>(); /** Each guard that isn't idle, by agent name, as the guard reports it. An @@ -9101,6 +9192,7 @@ export class AgentFramework { // Addressed-while-closed invitation — parity with the push-event path // (channels.publish surfaces like portal-mcpl deliver mentions here). + this.noteDirectMessageTags(event.channelId, event.tags); const incomingContent = [...event.content]; { const invitation = this.buildClosedChannelInvitation({ @@ -10160,6 +10252,7 @@ export class AgentFramework { const content = [...event.content]; if (triggerChannel) { + this.noteDirectMessageTags(triggerChannel.channelId, event.tags); const origin = (event.origin ?? {}) as Record; const invitation = this.buildClosedChannelInvitation({ serverId: event.serverId, @@ -10643,9 +10736,15 @@ export class AgentFramework { ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` : ''; + // Words that stayed in a closed channel that isn't a DM say so, unless + // the agent has turned the note off. + const closedNotes = this.closedChannelNotes(agent.name, seen); + const closedNote = closedNotes.length > 0 + ? ` · ${closedNotes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` + : ''; const text = shown.length > 0 - ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` + ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}` : `[delivered] nothing — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( @@ -17987,6 +18086,15 @@ export class AgentFramework { // A closed channel stays closed, and where a turn with no trigger // speaks still follows the agent there. this.channelRegistry.noteAnswered(engagedChannel, serverId, decidedSince); + // The send's answer says so, where the agent's words stayed in a + // closed channel that isn't a DM. + const [note] = toolName === 'send_dm' ? [] : this.closedChannelNotes(agentName, [engagedChannel], serverId); + if (note) { + result.content = [ + ...(result.content ?? []), + { type: 'text', text: `[closed channel] ${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, + ]; + } } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 5c4685052..258985dbf 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1369,6 +1369,12 @@ export class ChannelRegistry { return this.findChannelEntry(channelId)?.open === true; } + /** Whether a channel is a DM by what the registry knows of it: its + * metadata, or the id and label shapes DMs take (isDmChannelId). */ + isDirectMessageChannel(channelId: string): boolean { + return this.isDmChannelId(channelId); + } + getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state; } diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 551c715b2..96ed57edf 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1505,6 +1505,8 @@ describe('present while acting', () => { decisionSequence: () => 0, resolveSendTarget: (target: string) => target, noteAnswered: () => {}, + // An open channel, so the send's answer carries no closed-channel note. + isChannelOpen: () => true, }; framework.turnRouting = new Map([['assistant', sending]]); const results: unknown[] = []; diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 774ad2595..95342f971 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -5,7 +5,10 @@ * stays closed, a channel whose open failed isn't opened again, and a * tuned-out channel stays tuned out. The call still * records the channel as engaged this turn, by its descriptor id, for the - * mid-turn re-pin. + * mid-turn re-pin. Where the words land in a closed channel that isn't a DM, + * the send's answer, or the turn's delivery line for routed speech, says the + * channel stays closed, what still reaches the agent from it, and how to open + * it, until the agent turns that note off with agent_settings. */ import { test } from 'node:test'; @@ -20,6 +23,12 @@ import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; const channelId = 'discord:g1:444555'; const origin = { kind: 'agent' as const, agentName: 'sol' }; +/** The note a send's answer carries when it lands in the closed #upkeep. */ +const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: speaking into a closed channel doesn't open it. ` + + 'Messages there that address you, such as an @-mention, still reach you; to hear the rest of its traffic, ' + + `call channel_open with channelId "${channelId}" and serverId "discord". ` + + 'To stop this note, set closed_channel_note to false with agent_settings.'; + /** A real ChannelRegistry with one registered channel, and a framework with * just what dispatchMcplToolCall reads. */ async function harness() { @@ -28,13 +37,15 @@ async function harness() { let refuseOpens = false; // While set, the send's result waits for it. let hold: Promise | null = null; + // The raw channel id the send's answer names. + let sentTo = '444555'; const server = { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), async sendToolsCall() { if (hold) await hold; // discord-mcpl's send tools answer with the posted message and the raw channel id. - return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: '444555' }) }] }; + return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: sentTo }) }] }; }, async sendChannelsOpen(params: { channelId?: string }) { opens.push(params); @@ -63,6 +74,15 @@ async function harness() { // The sending turn's routing, whose engaged set a send tool records into. framework.turnRouting = new Map([['sol', { engaged: new Set() }]]); framework.emitTrace = () => {}; + // The framework's state store, where agent_settings keeps closed_channel_note. + let frameworkState: unknown = null; + framework.store = { + getStateJson: () => frameworkState, + setStateJson: (_id: string, value: unknown) => { frameworkState = value; }, + }; + framework.agents = new Map([['sol', {}]]); + // Channels seen delivering a chat:dm message (a class field the constructor sets). + framework.directMessageChannels = new Set(); const windowMessages: string[] = []; framework.addMessage = (_role: string, content: Array<{ text?: string }>) => { windowMessages.push(content.map((b) => b.text ?? '').join('')); @@ -72,21 +92,22 @@ async function harness() { /** Dispatch reply_message by the raw id; resolve with its result and the * turn's engaged channels as they stood when the result was queued (the * mid-turn re-pin reads them when that event is processed). */ - async function reply(): Promise<{ success: boolean; engagedAtResult: string[] }> { - const out = await new Promise<{ success: boolean; engagedAtResult: string[] }>((resolve, reject) => { + async function reply(tool = 'reply_message'): Promise<{ success: boolean; engagedAtResult: string[]; data: string }> { + const out = await new Promise<{ success: boolean; engagedAtResult: string[]; data: string }>((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); - framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean } }) => { + framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown } }) => { if (event.type === 'tool-result' && event.callId === 'call-reply') { clearTimeout(timeout); resolve({ success: event.result.success, engagedAtResult: [...(framework.turnRouting.get('sol')?.engaged ?? [])], + data: String(event.result.data ?? ''), }); } }; framework.dispatchMcplToolCall( 'sol', - { id: 'call-reply', name: 'discord--reply_message', input: { channelId: '444555', messageId: 'm1', content: 'answering, without moving in' } }, + { id: 'call-reply', name: `discord--${tool}`, input: { channelId: sentTo, messageId: 'm1', content: 'answering, without moving in' } }, 'discord', 'discord', ); @@ -97,12 +118,110 @@ async function harness() { } return { - registry, opens, windowMessages, reply, + registry, framework, opens, windowMessages, reply, setRefuseOpens: (v: boolean) => { refuseOpens = v; }, setHold: (p: Promise | null) => { hold = p; }, + setSentTo: (raw: string) => { sentTo = raw; }, + state: () => frameworkState as { closedChannelNotes?: Record } | null, }; } +test("a send that stays in a closed channel says so, what still reaches the agent, and how to open it, until they turn the note off", async () => { + const { registry, framework, reply, state } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + + const first = await reply(); + assert.equal(first.success, true); + assert.ok(first.data.endsWith(`\n${UPKEEP_NOTE}`), first.data); + assert.ok(first.data.startsWith('{"messageId":"m9"'), "the server's own answer comes first"); + + // Turned off through agent_settings, for good: kept in the framework's state. + const settings = framework.closedChannelNoteSettingsExtension(); + assert.deepEqual(settings.get('sol'), { closed_channel_note: true }); + assert.deepEqual(settings.update('sol', { closed_channel_note: false }), { closed_channel_note: false }); + assert.deepEqual(state()?.closedChannelNotes, { sol: false }); + assert.ok(!(await reply()).data.includes('[closed channel]'), 'no note once turned off'); + assert.throws(() => settings.update('sol', { closed_channel_note: 'off' }), /closed_channel_note must be a boolean/); + + // Reset brings the default back. + assert.deepEqual(settings.reset('sol'), { closed_channel_note: true }); + assert.ok((await reply()).data.endsWith(UPKEEP_NOTE)); +}); + +test("a note that can't be worked out leaves the send's answer as the server gave it", async () => { + const { registry, framework, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + // The setting can't be read, as after the store has closed. + framework.store = { getStateJson: () => { throw new Error('store closed'); } }; + const { success, data } = await reply(); + assert.equal(success, true, 'the send was delivered, and says so'); + assert.equal(data, JSON.stringify({ messageId: 'm9', channelId: '444555' })); +}); + +test('a send into an open channel, or into a DM by any sign of one, carries no closed-channel note', async () => { + { + const { registry, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.ok(!(await reply()).data.includes('[closed channel]'), 'open'); + } + { + // send_dm sends to a DM, whatever the registry knows of it. + const { reply } = await harness(); + assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); + } + { + // A channel that delivered a message tagged chat:dm. + const { framework, reply } = await harness(); + framework.noteDirectMessageTags(channelId, ['chat:addressed', 'chat:dm']); + assert.ok(!(await reply()).data.includes('[closed channel]'), 'chat:dm seen'); + } + { + // One the registry knows as a DM by its metadata. + const { registry, reply, setSentTo } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', + address: { channelId: '777' }, metadata: { channelType: 'dm' } }], + } as never); + setSentTo('777'); + const { data, engagedAtResult } = await reply(); + assert.deepEqual(engagedAtResult, ['discord:dm:777']); + assert.ok(!data.includes('[closed channel]'), 'registry DM'); + } +}); + +test("routed speech that stays in a closed channel says so in the turn's delivery line", async () => { + const { registry, framework } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', + address: { channelId: '777' }, metadata: { channelType: 'dm' } }], + } as never); + const lines: string[] = []; + const agent = { + name: 'sol', + proseRouting: 'auto', + getContextManager: () => ({ + addMessage: (_role: string, content: Array<{ text: string }>) => { lines.push(content[0]!.text); return 'm'; }, + }), + }; + const receipt = (...deliveries: string[]) => { + framework.appendProseDeliveryReceipt(agent, { deliveries, suppressed: 0 }); + return lines.pop(); + }; + // The same words as a send's note, without its tag. + const note = UPKEEP_NOTE.slice('[closed channel] '.length); + assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId}) · ${note}`); + // A DM says nothing more. + assert.equal(receipt('discord:dm:777'), '[delivered] plain speech → #Tess (discord:dm:777)'); + // Nor does an open channel, or any channel once the agent has turned the note off. + framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); + assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId})`); + framework.closedChannelNoteSettingsExtension().reset('sol'); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId})`); +}); + test('a reply_message into a channel the agent closed leaves it closed, and records the turn engagement', async () => { const { registry, opens, windowMessages, reply } = await harness(); // The agent had the channel open, then closed it. diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index e754b1034..770984dc5 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -47,6 +47,7 @@ function internals(framework: AgentFramework) { channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; handleMcplChannelIncoming(event: unknown): Promise; + directMessageChannels: Set; }; } @@ -351,6 +352,8 @@ describe('Trunk channel routing (item-3 redux)', () => { .join('\n') ?? ''; assert.match(text, /you there\?/); assert.doesNotMatch(text, /Channel invitation/); + // And the closed-channel note on the agent's words there is left out too. + assert.ok(i.directMessageChannels.has('discord:dm:1555'), 'known as a DM by its chat:dm message'); await framework.stop(); }); @@ -389,6 +392,8 @@ describe('Trunk channel routing (item-3 redux)', () => { await incomingOn('discord:dm:1555', 'antra: and one in private', ['chat:dm', 'chat:addressed', 'chat:private']); assert.match(lastText(), /one in private/); assert.doesNotMatch(lastText(), /Channel invitation/); + // Only the DM is known as one, for the closed-channel note. + assert.deepEqual([...i.directMessageChannels], ['discord:dm:1555']); await framework.stop(); }); From 6011543cf9329783d518cffb18b2062cba3943a9 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Thu, 15 Oct 2026 22:45:34 -0400 Subject: [PATCH 12/37] fix(channels): af-speak4's round on the closed-channel note af-speak4 (wf-1677) read 89dd16dd: 80 findings in 25 issues, audit clean. Ada-1017 made the three design calls (room-203 #110962, #111061): - A DM is what the registry knows as one, and nothing else (I1, I3, I4, I5, I6). The chat:dm sightings are gone (directMessageChannels, noteDirectMessageTags and its two ingest calls). A chat:dm tag describes one message: eidoverse-worlds puts it on a whisper inside its shared world channel, so reading it as the channel's left that channel without the note for the life of the process; it was also lost at a restart and missed under conversation routing. isDirectMessageChannel (channelType 'dm' metadata, a :dm: id, a 'DM: ' label; persisted) decides alone, and a send_dm's own answer still carries no note (Ada #111061: the tool names a DM). A DM the registry doesn't know, such as portal-mcpl's ('#name (dm)' label, channelType 'text' etc.), gets the note, whose words are true there too. - The note says only what holds on every connector (I2): "...still reach you; channel_open with channelId "..." and serverId "..." opens it." The old "to hear the rest of its traffic, call channel_open" was false on slack-mcpl, whose ambient delivery follows its own subscription, not MCPL open state. - The note goes exactly where main opened the channel (I10, I13). Explicit-mode >> speech opened a closed channel too but has no [delivered] line, so deliverProseEnvelope now records its deliveries and the turn ends with the note alone (appendExplicitClosedChannelNote), in a send's words. At a tool-ended turn explicit mode doesn't hold the turn for its deliveries, so the note follows them, unless a new stream has started by then: nothing is added to a window mid-stream. The sleep announcement and channel_publish never opened a channel: no note. Also, from the sheet: - I18: the containment's catch reads a message only from an Error, so a thrown undefined stays contained. - I20: the setting's description says the note is left off in a DM. - Tests (I7, I8, I12, I14, I16, I17): a metadata-only DM; the setting through the agent_settings tool, kept beside tool_result_guard across a framework restart, and reset alone; containment on the send path (store, registry lookup, a thrown undefined) and on the delivery line, with the logged line; a refused send carries no note; two closed channels in one delivery line, the stop sentence once; explicit mode's turn-end note, after a tool-ended turn, and none once the next turn's stream started. - trunk-channel-routing.test.ts is back to e7dfa309's (its DM-record checks went with the sightings). - docs/explicit-prose-routing.md and the breaking fragment say where the note appears and what counts as a DM. Judged without a change: I9 (not-worth), I11, I15, I21, I24, I25 (intended), I23 (not-real). --- changelog.d/speaking-never-opens.breaking.md | 14 +- docs/explicit-prose-routing.md | 6 +- src/framework.ts | 74 +++++--- test/explicit-prose-routing.test.ts | 120 +++++++++++++ test/send-into-closed-channel.test.ts | 173 ++++++++++++++++--- test/trunk-channel-routing.test.ts | 5 - 6 files changed, 340 insertions(+), 52 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index a0c7e6e0c..b333743f7 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -8,11 +8,15 @@ opens it with `channel_open`; speaking no longer does. Messages that address the agent still arrive while a channel is closed. - **What residents read:** where an agent's words land in a closed channel that isn't a DM, the - send tool's answer, or for routed speech the turn's `[delivered]` line, says that the channel - stays closed, that messages addressing the agent still reach it, and how to open it with - `channel_open`. The agent turns that note off for good with `agent_settings`' new - `closed_channel_note`, which is on by default. A channel counts as a DM when the send was - `send_dm`, when it has delivered a `chat:dm` message, or when the registry knows it as one. + send tool's answer, for routed speech the turn's `[delivered]` line, or in `explicit` routing + mode, which has no such line, a line of its own at the turn's end, says that the channel stays + closed, that messages addressing the agent still reach it, and that `channel_open` opens it. + The agent turns that note off for good with `agent_settings`' new `closed_channel_note`, which + is on by default. A channel counts as a DM when the registry knows it as one (a + `channelType: 'dm'` descriptor, a `:dm:` id or a `DM: ` label), and a `send_dm`'s answer never + carries the note. A DM the registry doesn't know, such as a portal-mcpl DM, gets the note, + which is true there too. In `explicit` mode, speech still being delivered when a tool ends the + turn gets the line once it's delivered, unless the next turn has started by then. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a closed guild channel used to open it, and so turn its reactions on. Now reactions to the reply arrive only where the agent diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 35ad03c47..e0fcf68ef 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -54,7 +54,11 @@ agent (then notices append without waking, breaking any loop). Send tools (`send_message` etc.) and channel lifecycle work the same in explicit mode as in the other modes. Speaking never opens a channel, so a -`>>` send into a closed channel publishes there and leaves it closed. +`>>` send into a closed channel publishes there and leaves it closed. Explicit +mode has no `[delivered]` receipt, so where a turn's `>>` speech stayed in a +closed channel that isn't a DM, the turn ends with a `[closed channel]` line +that says so, as a send tool's answer does, unless the agent has turned that +note off (`agent_settings`' `closed_channel_note`). ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index 7dd9333d2..7a708150a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4033,8 +4033,8 @@ export class AgentFramework { properties: { closed_channel_note: { type: 'boolean', - description: 'Add a note when your words go to a closed channel, saying that it stays closed, what still ' + - 'reaches you from it, and how to open it. On by default. Persists across turns and restarts until changed.', + description: "Add a note when your words go to a closed channel that isn't a DM, saying that it stays closed, " + + 'what still reaches you from it, and how to open it. On by default. Persists across turns and restarts until changed.', }, }, keys: ['closed_channel_note'], @@ -4047,31 +4047,29 @@ export class AgentFramework { }; } - /** Channels that have delivered a message tagged chat:dm in this process: - * every message of a DM reaches the agent, open or not, so the closed-channel - * note is left off there. */ - private readonly directMessageChannels = new Set(); - - private noteDirectMessageTags(channelId: string, tags: string[] | undefined): void { - if (tags?.includes('chat:dm')) this.directMessageChannels.add(channelId); - } - /** What an agent is told when their words go to a channel that stays closed, - * or undefined where nothing needs saying: an open channel, or a DM, known by - * a chat:dm message it delivered or by what the registry knows of it. */ + * or undefined where nothing needs saying: an open channel, or a channel the + * registry knows as a DM (isDirectMessageChannel), every message of which + * reaches the agent, open or not. A chat:dm tag describes one message, not + * its channel: a whisper inside a shared channel carries one too. A DM the + * registry doesn't know gets the note, which is true there as well. The + * words hold on every connector: what a closed channel still delivers + * beyond messages that address the agent is the connector's (slack-mcpl + * delivers ambient traffic by its own subscription), so the note promises + * only those, and what channel_open does. */ private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { const registry = this.channelRegistry; if (!registry || registry.isChannelOpen(channelId)) return undefined; - if (this.directMessageChannels.has(channelId) || registry.isDirectMessageChannel(channelId)) return undefined; + if (registry.isDirectMessageChannel(channelId)) return undefined; const label = registry.getDescriptor(channelId)?.label; const place = label && label !== channelId ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId; const open = serverId - ? `call channel_open with channelId "${channelId}" and serverId "${serverId}"` - : `call channel_open with channelId "${channelId}"`; + ? `channel_open with channelId "${channelId}" and serverId "${serverId}"` + : `channel_open with channelId "${channelId}"`; return `${place} stays closed: speaking into a closed channel doesn't open it. Messages there that address you, ` + - `such as an @-mention, still reach you; to hear the rest of its traffic, ${open}.`; + `such as an @-mention, still reach you; ${open} opens it.`; } /** How to stop the closed-channel note, said once wherever it appears. */ @@ -4088,7 +4086,7 @@ export class AgentFramework { // The setting is read only when there's a note to give. return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : []; } catch (err) { - console.error(`[closed-channel-note] for ${agentName} failed: ${(err as Error).message}`); + console.error(`[closed-channel-note] for ${agentName} failed: ${err instanceof Error ? err.message : String(err)}`); return []; } } @@ -9192,7 +9190,6 @@ export class AgentFramework { // Addressed-while-closed invitation — parity with the push-event path // (channels.publish surfaces like portal-mcpl deliver mentions here). - this.noteDirectMessageTags(event.channelId, event.tags); const incomingContent = [...event.content]; { const invitation = this.buildClosedChannelInvitation({ @@ -10252,7 +10249,6 @@ export class AgentFramework { const content = [...event.content]; if (triggerChannel) { - this.noteDirectMessageTags(triggerChannel.channelId, event.tags); const origin = (event.origin ?? {}) as Record; const invitation = this.buildClosedChannelInvitation({ serverId: event.serverId, @@ -10758,6 +10754,30 @@ export class AgentFramework { } } + /** + * Explicit mode's counterpart at logical turn end. It has no `[delivered]` + * receipt, since the agent named every destination itself. Its `>>` speech + * opened a closed channel until speaking never opened one, so where that + * speech stayed in a closed channel that isn't a DM, the turn ends with the + * closed-channel note alone, in a send's words. No-op otherwise. + */ + private appendExplicitClosedChannelNote(agent: Agent, routing: TurnRouting): void { + const delivered = new Set(routing.deliveries); + routing.deliveries = []; + const notes = this.closedChannelNotes(agent.name, delivered); + if (notes.length === 0) return; + try { + const mid = agent.getContextManager().addMessage( + 'user', + [{ type: 'text', text: `[closed channel] ${notes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }], + { system: true, kind: 'delivery-receipt' } as MessageMetadata, + ); + this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); + } catch (err) { + console.error('closed-channel note append failed:', err); + } + } + /** * Announce-on-change locus notice (turn-frozen routing). Appends a durable * `[routing]` window message when the turn's effective outbound locus @@ -11232,6 +11252,8 @@ export class AgentFramework { try { const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); + // For the turn's closed-channel note (appendExplicitClosedChannelNote). + this.recordProseDelivery(routing, result); if (result?.delivered) { this.proseBounceStreaks.delete(name); if (usedClipboard) this.proseClipboards.delete(name); @@ -13063,10 +13085,13 @@ export class AgentFramework { // turn whose last response is empty awaits it here, so the // receipt and the continuation see its last rounds' deliveries. // Locus and hybrid mode; explicit-mode envelopes acknowledge - // themselves through the prose gateway. + // themselves through the prose gateway, and its turn ends with + // the closed-channel note alone, where one is due. await turnSpeechChain; if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent, streamRouting); + } else if (!trigger?.suppressProse) { + this.appendExplicitClosedChannelNote(agent, streamRouting); } // A `!` continuation this turn asked for. This gives prose the @@ -13237,6 +13262,13 @@ export class AgentFramework { if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') { await turnSpeechChain; this.appendProseDeliveryReceipt(agent, streamRouting); + } else if (cancelKind === 'turn_ended' && !trigger?.suppressProse) { + // Explicit mode doesn't hold the turn for its deliveries, so + // the note comes once they've run, unless a new stream has + // started by then: nothing is added to a window mid-stream. + void turnSpeechChain.then(() => { + if (agent.streamId === myStreamId) this.appendExplicitClosedChannelNote(agent, streamRouting); + }); } // A `!` an earlier round asked for still starts its // continuation when a tool ends the turn, once the turn's diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 5159458e4..00385d682 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1418,6 +1418,126 @@ describe('explicit prose routing', () => { await framework.stop(); }); + /** A registry stub for the closed-channel note: #cafe is closed, laria's DM + * is closed and the registry knows it as a DM, and #alpha is open. */ + function closedNoteRegistry(framework: AgentFramework, held?: Promise) { + const routed: Array<{ text: string; locus: string | null }> = []; + const known: Record = { + '#alpha': { channelId: 'chan-alpha', label: '#alpha' }, + '#cafe': { channelId: 'discord:g:cafe', label: '#cafe' }, + '@laria': { channelId: 'discord:dm:99', label: 'DM: laria' }, + }; + const labels: Record = { 'chan-alpha': '#alpha', 'discord:g:cafe': '#cafe', 'discord:dm:99': 'DM: laria' }; + const registry: Record = { + resolveProseTarget: (spec: string) => known[spec] ?? { error: `no channel matches "${spec}"` }, + routeSpeech: async (_agent: string, text: string, locus?: string | null) => { + routed.push({ text, locus: locus ?? null }); + if (held) await held; + return { delivered: true, channelId: locus ?? '' }; + }, + resolveLocus: () => 'chan-alpha', + getDefaultPublishChannel: () => null, + isChannelOpen: (id: string) => id === 'chan-alpha', + isDirectMessageChannel: (id: string) => id === 'discord:dm:99', + getDescriptor: (id: string) => (labels[id] ? { label: labels[id] } : undefined), + getChannelServerId: () => 'discord', + getChannelTools: () => [], + }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { + get: (target, prop: string) => (prop in target ? target[prop] : () => undefined), + }); + return routed; + } + + const CAFE_NOTE = "[closed channel] #cafe (discord:g:cafe) stays closed: speaking into a closed channel doesn't open it. " + + 'Messages there that address you, such as an @-mention, still reach you; ' + + 'channel_open with channelId "discord:g:cafe" and serverId "discord" opens it. ' + + 'To stop this note, set closed_channel_note to false with agent_settings.'; + + function windowTexts(framework: AgentFramework): string[] { + return framework.getAgent('assistant')!.getContextManager().getAllMessages() + .flatMap((m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : []))); + } + + it("speech that stays in a closed channel ends the turn with the closed-channel note alone, and a DM's or an open channel's gets none", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe hello there\n>>@laria and you\n>>#alpha and everyone' }, + ] as ContentBlock[])); + const framework = await createFramework(); + const routed = closedNoteRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe', 'discord:dm:99', 'chan-alpha']); + const texts = windowTexts(framework); + assert.deepEqual(texts.filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE]); + assert.ok(!texts.some((t) => t.startsWith('[delivered]')), 'explicit mode still has no delivery line'); + await framework.stop(); + }); + + it('a turn a tool ends gets the note once its speech has been delivered', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe heading out now' }, + { type: 'tool_use', id: 'e1', name: 'robot--move', input: { dir: 'out' } }, + ] as ContentBlock[], 'tool_use')); + const framework = await createFramework(); + const routed = closedNoteRegistry(framework); + // The move ends the turn, as skip_reply does, without keeping its round's prose private. + module.handleToolCall = async (call: ToolCall): Promise => { + module.calls.push(call); + return { success: true, endTurn: true, data: { ok: true } }; + }; + + trigger(framework); + await framework.runUntilIdle(); + for (const end = Date.now() + 5000; !windowTexts(framework).includes(CAFE_NOTE) && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + assert.equal(module.calls.length, 1, 'the move ended the turn'); + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); + assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE]); + await framework.stop(); + }); + + it("a tool-ended turn's note isn't added once the next turn's stream has started", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe heading out now' }, + { type: 'tool_use', id: 'e1', name: 'robot--move', input: { dir: 'out' } }, + ] as ContentBlock[], 'tool_use')); + let release!: () => void; + const framework = await createFramework(); + const routed = closedNoteRegistry(framework, new Promise((resolve) => { release = resolve; })); + module.handleToolCall = async (call: ToolCall): Promise => { + module.calls.push(call); + return { success: true, endTurn: true, data: { ok: true } }; + }; + let turns = 0; + framework.onTrace((e) => { if ((e as { type: string }).type === 'inference:started') turns++; }); + // The next turn answers with nothing to deliver. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + assert.equal(module.calls.length, 1, 'the move ended the first turn while its speech was still being delivered'); + trigger(framework); + // The next turn's end waits behind the held post, so it can't go idle yet. + const idle = framework.runUntilIdle(); + for (const end = Date.now() + 5000; turns < 2 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(turns, 2, 'the next turn has started'); + release(); + await idle; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); + assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [], + 'nothing is added to the window after another stream has started'); + await framework.stop(); + }); + it('multi-envelope prose: each >> line routes independently (Tilde scenario)', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha part one for the channel\n>>@laria part two, just for you\nwith a second line\n>> quoted arrow stays in body' }, diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 95342f971..f548c2e4f 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -13,6 +13,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { AgentFramework } from '../src/framework.js'; import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; @@ -25,8 +28,8 @@ const origin = { kind: 'agent' as const, agentName: 'sol' }; /** The note a send's answer carries when it lands in the closed #upkeep. */ const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: speaking into a closed channel doesn't open it. ` + - 'Messages there that address you, such as an @-mention, still reach you; to hear the rest of its traffic, ' + - `call channel_open with channelId "${channelId}" and serverId "discord". ` + + 'Messages there that address you, such as an @-mention, still reach you; ' + + `channel_open with channelId "${channelId}" and serverId "discord" opens it. ` + 'To stop this note, set closed_channel_note to false with agent_settings.'; /** A real ChannelRegistry with one registered channel, and a framework with @@ -39,11 +42,16 @@ async function harness() { let hold: Promise | null = null; // The raw channel id the send's answer names. let sentTo = '444555'; + // While set, the server refuses the send, as discord-mcpl does without Send Messages. + let refuseSends = false; const server = { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), async sendToolsCall() { if (hold) await hold; + if (refuseSends) { + return { content: [{ type: 'text', text: `Missing Access in channel ${sentTo}` }], isError: true }; + } // discord-mcpl's send tools answer with the posted message and the raw channel id. return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: sentTo }) }] }; }, @@ -81,8 +89,6 @@ async function harness() { setStateJson: (_id: string, value: unknown) => { frameworkState = value; }, }; framework.agents = new Map([['sol', {}]]); - // Channels seen delivering a chat:dm message (a class field the constructor sets). - framework.directMessageChannels = new Set(); const windowMessages: string[] = []; framework.addMessage = (_role: string, content: Array<{ text?: string }>) => { windowMessages.push(content.map((b) => b.text ?? '').join('')); @@ -92,16 +98,17 @@ async function harness() { /** Dispatch reply_message by the raw id; resolve with its result and the * turn's engaged channels as they stood when the result was queued (the * mid-turn re-pin reads them when that event is processed). */ - async function reply(tool = 'reply_message'): Promise<{ success: boolean; engagedAtResult: string[]; data: string }> { - const out = await new Promise<{ success: boolean; engagedAtResult: string[]; data: string }>((resolve, reject) => { + async function reply(tool = 'reply_message'): Promise<{ success: boolean; engagedAtResult: string[]; data: string; error: string }> { + const out = await new Promise<{ success: boolean; engagedAtResult: string[]; data: string; error: string }>((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); - framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown } }) => { + framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown; error?: string } }) => { if (event.type === 'tool-result' && event.callId === 'call-reply') { clearTimeout(timeout); resolve({ success: event.result.success, engagedAtResult: [...(framework.turnRouting.get('sol')?.engaged ?? [])], data: String(event.result.data ?? ''), + error: String(event.result.error ?? ''), }); } }; @@ -122,6 +129,7 @@ async function harness() { setRefuseOpens: (v: boolean) => { refuseOpens = v; }, setHold: (p: Promise | null) => { hold = p; }, setSentTo: (raw: string) => { sentTo = raw; }, + setRefuseSends: (v: boolean) => { refuseSends = v; }, state: () => frameworkState as { closedChannelNotes?: Record } | null, }; } @@ -149,18 +157,71 @@ test("a send that stays in a closed channel says so, what still reaches the agen assert.ok((await reply()).data.endsWith(UPKEEP_NOTE)); }); -test("a note that can't be worked out leaves the send's answer as the server gave it", async () => { +test("a note that can't be worked out leaves the delivered words as they were, and the operator's log says why", async () => { const { registry, framework, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); - // The setting can't be read, as after the store has closed. - framework.store = { getStateJson: () => { throw new Error('store closed'); } }; - const { success, data } = await reply(); - assert.equal(success, true, 'the send was delivered, and says so'); - assert.equal(data, JSON.stringify({ messageId: 'm9', channelId: '444555' })); + const answer = JSON.stringify({ messageId: 'm9', channelId: '444555' }); + const logged: string[] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => { + const line = args.map(String).join(' '); + if (line.startsWith('[closed-channel-note]')) logged.push(line); + }; + try { + // The setting can't be read, as after the store has closed. + const store = framework.store; + framework.store = { getStateJson: () => { throw new Error('store closed'); } }; + let sent = await reply(); + assert.equal(sent.success, true, 'the send was delivered, and says so'); + assert.equal(sent.data, answer); + assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: store closed']); + framework.store = store; + + // The registry can't say whether the channel is a DM, and then a lookup + // throws something that isn't an Error at all. + const isDirectMessageChannel = registry.isDirectMessageChannel; + registry.isDirectMessageChannel = () => { throw new Error('lookup failed'); }; + sent = await reply(); + assert.equal(sent.success, true); + assert.equal(sent.data, answer); + assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: lookup failed']); + registry.isDirectMessageChannel = () => { throw undefined; }; + sent = await reply(); + assert.equal(sent.success, true); + assert.equal(sent.data, answer); + assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: undefined']); + registry.isDirectMessageChannel = isDirectMessageChannel; + + // Routed speech's delivery line stands without the note. + const lines: string[] = []; + const agent = { + name: 'sol', + proseRouting: 'locus', + getContextManager: () => ({ + addMessage: (_role: string, content: Array<{ text: string }>) => { lines.push(content[0]!.text); return 'm'; }, + }), + }; + framework.store = { getStateJson: () => { throw new Error('store closed'); } }; + framework.appendProseDeliveryReceipt(agent, { deliveries: [channelId], suppressed: 0 }); + assert.deepEqual(lines, [`[delivered] plain speech → #upkeep (${channelId})`]); + assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: store closed']); + } finally { + console.error = consoleError; + } }); -test('a send into an open channel, or into a DM by any sign of one, carries no closed-channel note', async () => { +test('a send the server refuses carries no closed-channel note', async () => { + const { registry, reply, setRefuseSends } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + setRefuseSends(true); + const refused = await reply(); + assert.equal(refused.success, false); + assert.equal(refused.error, 'Missing Access in channel 444555', "the server's refusal, and nothing about the channel"); +}); + +test('a send into an open channel, a DM the registry knows, or by send_dm carries no closed-channel note', async () => { { const { registry, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); @@ -171,12 +232,6 @@ test('a send into an open channel, or into a DM by any sign of one, carries no c const { reply } = await harness(); assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); } - { - // A channel that delivered a message tagged chat:dm. - const { framework, reply } = await harness(); - framework.noteDirectMessageTags(channelId, ['chat:addressed', 'chat:dm']); - assert.ok(!(await reply()).data.includes('[closed channel]'), 'chat:dm seen'); - } { // One the registry knows as a DM by its metadata. const { registry, reply, setSentTo } = await harness(); @@ -189,6 +244,19 @@ test('a send into an open channel, or into a DM by any sign of one, carries no c assert.deepEqual(engagedAtResult, ['discord:dm:777']); assert.ok(!data.includes('[closed channel]'), 'registry DM'); } + { + // One the registry knows as a DM by its metadata alone: neither its id nor + // its label has a DM's shape. + const { registry, reply, setSentTo } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:private-room-42', type: 'discord', label: 'Tess', direction: 'bidirectional', + address: { channelId: '42' }, metadata: { channelType: 'dm' } }], + } as never); + setSentTo('42'); + const { data, engagedAtResult } = await reply(); + assert.deepEqual(engagedAtResult, ['discord:private-room-42']); + assert.ok(!data.includes('[closed channel]'), 'registry DM by metadata'); + } }); test("routed speech that stays in a closed channel says so in the turn's delivery line", async () => { @@ -212,6 +280,16 @@ test("routed speech that stays in a closed channel says so in the turn's deliver // The same words as a send's note, without its tag. const note = UPKEEP_NOTE.slice('[closed channel] '.length); assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId}) · ${note}`); + // Two closed channels in one turn: a note for each, and how to stop them once. + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:555666', type: 'discord', label: '#standup', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '555666' } }], + } as never); + const stop = 'To stop this note, set closed_channel_note to false with agent_settings.'; + const upkeepNote = note.slice(0, -(stop.length + 1)); + const standupNote = upkeepNote.replaceAll('#upkeep', '#standup').replaceAll(channelId, 'discord:g1:555666'); + assert.equal(receipt(channelId, 'discord:g1:555666'), + `[delivered] plain speech → #upkeep (${channelId}) · #standup (discord:g1:555666) · ${upkeepNote} ${standupNote} ${stop}`); // A DM says nothing more. assert.equal(receipt('discord:dm:777'), '[delivered] plain speech → #Tess (discord:dm:777)'); // Nor does an open channel, or any channel once the agent has turned the note off. @@ -295,3 +373,58 @@ test('a reply_message still in flight when the agent closes its channel marks no registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); assert.equal(registry.getDefaultPublishChannel(), null, 'the close, made while the reply was in flight, stands'); }); + +test("closed_channel_note is an agent_settings key, kept in the framework's state beside the others across a restart", async () => { + const dir = mkdtempSync(join(tmpdir(), 'af-closed-channel-note-')); + const config = () => ({ + storePath: join(dir, 'store'), + membrane: {} as never, + agents: [{ name: 'sol', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + /** One agent_settings call, as the agent makes it, and its result. */ + const settings = (fw: AgentFramework, input: Record) => { + const internals = fw as unknown as { + pushEvent(event: unknown): void; + dispatchAgentSettingsToolCall(agentName: string, call: unknown): void; + }; + const pushEvent = internals.pushEvent; + let result: { success: boolean; data?: Record; error?: string } | undefined; + internals.pushEvent = (event: { type: string; result: typeof result }) => { if (event.type === 'tool-result') result = event.result; }; + try { + internals.dispatchAgentSettingsToolCall('sol', { id: 'call-settings', name: 'agent_settings', input }); + } finally { + internals.pushEvent = pushEvent; + } + assert.ok(result, 'agent_settings answered'); + return result; + }; + let framework = await AgentFramework.create(config()); + try { + const tool = framework.getAllTools().find((t) => t.name === 'agent_settings')!; + const property = (tool.inputSchema.properties as Record).closed_channel_note; + assert.equal(property.type, 'boolean'); + assert.match(property.description, /closed channel that isn't a DM/); + assert.equal(settings(framework, { action: 'get' }).data?.closed_channel_note, true, 'on by default'); + + // Another framework-owned setting, kept in the same state. + assert.equal(settings(framework, { action: 'update', tool_result_guard: true }).data?.tool_result_guard, true); + const off = settings(framework, { action: 'update', closed_channel_note: false }); + assert.equal(off.data?.closed_channel_note, false); + assert.equal(off.data?.tool_result_guard, true, "turning the note off leaves the other setting's value"); + assert.match(String(settings(framework, { action: 'update', closed_channel_note: 'no' }).error), /closed_channel_note must be a boolean/); + + await framework.stop(); + framework = await AgentFramework.create(config()); + const after = settings(framework, { action: 'get' }); + assert.equal(after.data?.closed_channel_note, false, 'still off after a restart'); + assert.equal(after.data?.tool_result_guard, true, 'and the other setting with it'); + + const reset = settings(framework, { action: 'reset', settings: ['closed_channel_note'] }); + assert.equal(reset.data?.closed_channel_note, true); + assert.equal(reset.data?.tool_result_guard, true, 'a reset of the note resets nothing else'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 770984dc5..e754b1034 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -47,7 +47,6 @@ function internals(framework: AgentFramework) { channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; handleMcplChannelIncoming(event: unknown): Promise; - directMessageChannels: Set; }; } @@ -352,8 +351,6 @@ describe('Trunk channel routing (item-3 redux)', () => { .join('\n') ?? ''; assert.match(text, /you there\?/); assert.doesNotMatch(text, /Channel invitation/); - // And the closed-channel note on the agent's words there is left out too. - assert.ok(i.directMessageChannels.has('discord:dm:1555'), 'known as a DM by its chat:dm message'); await framework.stop(); }); @@ -392,8 +389,6 @@ describe('Trunk channel routing (item-3 redux)', () => { await incomingOn('discord:dm:1555', 'antra: and one in private', ['chat:dm', 'chat:addressed', 'chat:private']); assert.match(lastText(), /one in private/); assert.doesNotMatch(lastText(), /Channel invitation/); - // Only the DM is known as one, for the closed-channel note. - assert.deepEqual([...i.directMessageChannels], ['discord:dm:1555']); await framework.stop(); }); From 09039b28d3b4732332565b9be5c3897be3b91b40 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Thu, 15 Oct 2026 23:25:19 -0400 Subject: [PATCH 13/37] docs(channels): name the closed-channel note's one false case, a channel muted on discord-mcpl closedChannelNoteFor's comment said the note's words hold on every connector. Greta-2425 found the exception reading discord-mcpl (room-203 #111287): a channel muted with mute_channel delivers nothing, messages that address the agent included (shouldEnterContext, src/server.ts:2465-2474 at 0f28761), so in a muted channel the host has closed, the note's second sentence is false. mute_channel's own close only drops discord-mcpl's record of the open channel (channelManager.close, src/channels.ts:164-166) and tells the host nothing, so the host can't see a mute (#111299, #111302). The words stay: a clause like 'unless you've muted it' would be false on slack-mcpl, where muting stops only ambient traffic, and the agent that muted the channel knows. Comment only. --- src/framework.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 7a708150a..1aba89f4e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4052,11 +4052,13 @@ export class AgentFramework { * registry knows as a DM (isDirectMessageChannel), every message of which * reaches the agent, open or not. A chat:dm tag describes one message, not * its channel: a whisper inside a shared channel carries one too. A DM the - * registry doesn't know gets the note, which is true there as well. The - * words hold on every connector: what a closed channel still delivers - * beyond messages that address the agent is the connector's (slack-mcpl - * delivers ambient traffic by its own subscription), so the note promises - * only those, and what channel_open does. */ + * registry doesn't know gets the note, which is true there as well. What a + * closed channel still delivers beyond messages that address the agent is + * the connector's (slack-mcpl delivers ambient traffic by its own + * subscription), so the note promises only those, and what channel_open + * does. One connector state the host can't see makes the second sentence + * false: a channel muted with discord-mcpl's mute_channel delivers nothing, + * messages that address the agent included. The agent that muted it knows. */ private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { const registry = this.channelRegistry; if (!registry || registry.isChannelOpen(channelId)) return undefined; From dbd921083ba931bb8b0bc5aede35d4d2db959639 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Thu, 15 Oct 2026 23:40:25 -0400 Subject: [PATCH 14/37] fix(channels): the sleep announcement gets the closed-channel note, and says what still reaches the agent The sleep announcement posts through routeSpeech, which on main opened a closed locus first (channel-registry.ts:3114 at e3b2c583). I had told Ada-1017 it never opened one (room-203 #110892); corrected in #111683. By her rule, the note goes wherever speaking used to open the channel, so it gets one too. Her call (#111744): - The sleep tool's result carries the note (closedChannelNote), decided from the announcement's channel as it goes out: whether that's a closed channel that isn't a DM is known before the post, and "speaking into a closed channel doesn't open it" holds whether the post lands or fails. The post stays fire-and-forget ("never blocks the result"). No announcement, no note: announce false, a silent turn, prose routing off, explicit mode with no target this turn, or a locus the registry doesn't know (whose post fails, as before). - In a closed channel that isn't a DM, the default announcement says "I'll still see messages addressed to me, but won't respond until I wake." On main its "I'll still see messages" was true because posting opened the channel; this change made it false there, and the words are for people. The words follow the channel, not the agent's note setting; an agent's own message is posted as given. Test: 'a sleep announced into a closed channel says only what addresses the agent reaches it, and the result carries the note' (locus and explicit mode, the agent's own words, the setting off, a DM, an open channel, an unknown locus, no announcement). The breaking fragment says it. --- changelog.d/speaking-never-opens.breaking.md | 4 + src/framework.ts | 41 +++++++--- test/send-into-closed-channel.test.ts | 84 +++++++++++++++++++- 3 files changed, 117 insertions(+), 12 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index b333743f7..c8ae10df2 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -11,6 +11,10 @@ send tool's answer, for routed speech the turn's `[delivered]` line, or in `explicit` routing mode, which has no such line, a line of its own at the turn's end, says that the channel stays closed, that messages addressing the agent still reach it, and that `channel_open` opens it. + So does the `sleep` tool's result (`closedChannelNote`) when its announcement goes to such a + channel, decided from the channel as the announcement goes out, and that announcement's + default words there become "I'll still see messages addressed to me, but won't respond until I + wake." The agent turns that note off for good with `agent_settings`' new `closed_channel_note`, which is on by default. A channel counts as a DM when the registry knows it as one (a `channelType: 'dm'` descriptor, a `:dm:` id or a `DM: ` label), and a `send_dm`'s answer never diff --git a/src/framework.ts b/src/framework.ts index 1aba89f4e..2426af77a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18538,8 +18538,9 @@ export class AgentFramework { // Announce in the TURN's frozen locus (best-effort; never blocks the // result). The sleep tool runs mid-turn, so the pin is live — same // authority as every other speech path, no live re-resolution. + // `destination` stays undefined where nothing is posted. + let destination: string | null | undefined; if (announce && this.channelRegistry) { - const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`; const agent = this.agents.get(agentName); const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true; if (silentTurn || agent?.proseRouting === 'disabled') { @@ -18547,21 +18548,36 @@ export class AgentFramework { } else if (agent?.proseRouting === 'explicit') { // Explicit mode: announce to the turn's sticky prose target if the // model has set one; otherwise stay quiet — never guess a channel. - const target = this.turnRouting.get(agentName)?.pin; - if (target) { - this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => { - console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); - }); - } else { + destination = this.turnRouting.get(agentName)?.pin; + if (!destination) { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } } else { - const locus = this.turnRouting.get(agentName)?.locus ?? null; - this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { - console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); - }); + destination = this.turnRouting.get(agentName)?.locus ?? null; + } + } + // Speaking never opens a channel, so in a closed one that isn't a DM the + // announcement says that only messages addressed to the agent reach it, + // and the result carries the closed-channel note, as a send's answer does. + // Both are decided from the channel as the announcement goes out: neither + // depends on the post landing, which stays fire-and-forget. + let closedChannelNote: string | undefined; + let staysClosed = false; + if (destination && this.channelRegistry?.getDescriptor(destination)) { + [closedChannelNote] = this.closedChannelNotes(agentName, [destination]); + try { + staysClosed = this.closedChannelNoteFor(destination, null) !== undefined; + } catch (err) { + console.error(`[sleep] ${agentName}: whether ${destination} stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); } } + if (destination !== undefined) { + const text = input.message ?? + `💤 Going quiet for ${human}. I'll still see messages${staysClosed ? ' addressed to me' : ''}, but won't respond until I wake.`; + this.channelRegistry!.routeSpeech(agentName, text, destination).catch((err) => { + console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); + }); + } console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`); // endTurn: the agent stops here and goes idle for the duration. @@ -18572,6 +18588,9 @@ export class AgentFramework { until, untilLocal: formatZonedDateTime(until, this.timeZone), timeZone: this.timeZone, + ...(closedChannelNote + ? { closedChannelNote: `[closed channel] ${closedChannelNote} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` } + : {}), }, endTurn: true, }); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index f548c2e4f..ea1fab5dd 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -44,6 +44,7 @@ async function harness() { let sentTo = '444555'; // While set, the server refuses the send, as discord-mcpl does without Send Messages. let refuseSends = false; + const published: Array<{ channelId: string; text: string }> = []; const server = { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), @@ -63,6 +64,11 @@ async function harness() { async sendChannelsClose() { return {}; }, + // What routed speech, such as a sleep announcement, posts. + async sendChannelsPublish(params: { channelId: string; content: Array<{ text: string }> }) { + published.push({ channelId: params.channelId, text: params.content[0]!.text }); + return { delivered: true }; + }, }; const serverRegistry = { getServer: (id: string) => (id === 'discord' ? server : undefined), @@ -125,7 +131,7 @@ async function harness() { } return { - registry, framework, opens, windowMessages, reply, + registry, framework, opens, windowMessages, reply, published, setRefuseOpens: (v: boolean) => { refuseOpens = v; }, setHold: (p: Promise | null) => { hold = p; }, setSentTo: (raw: string) => { sentTo = raw; }, @@ -428,3 +434,79 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat rmSync(dir, { recursive: true, force: true }); } }); + +test("a sleep announced into a closed channel says only what addresses the agent reaches it, and the result carries the note", async () => { + const { registry, framework, published } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', + address: { channelId: '777' }, metadata: { channelType: 'dm' } }], + } as never); + framework.eventGate = { setSleep: (seconds: number) => ({ until: Date.parse('2026-10-16T04:00:00Z') + seconds * 1000 }) }; + framework.activeTurnTriggers = new Map(); + framework.timeZone = 'UTC'; + const routing = framework.turnRouting.get('sol'); + /** One sleep call, as the agent makes it; its result once the posts it started have run. */ + const sleep = async (input: Record) => { + const result = await new Promise<{ success: boolean; data: Record; endTurn?: boolean }>((resolve) => { + framework.pushEvent = (event: { type: string; callId?: string; result: never }) => { + if (event.type === 'tool-result' && event.callId === 'call-sleep') resolve(event.result); + }; + framework.dispatchSleepToolCall('sol', { id: 'call-sleep', name: 'sleep', input }); + }); + await new Promise((resolve) => setImmediate(resolve)); + return result; + }; + const quiet = "💤 Going quiet for 10m. I'll still see messages, but won't respond until I wake."; + const addressed = "💤 Going quiet for 10m. I'll still see messages addressed to me, but won't respond until I wake."; + + // The turn's locus is the closed #upkeep. + routing.locus = channelId; + let slept = await sleep({ seconds: 600 }); + assert.equal(slept.success, true); + assert.equal(slept.endTurn, true); + assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + + // The agent's own words are posted as they are, and the note still comes. + slept = await sleep({ seconds: 600, message: 'back soon' }); + assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); + assert.deepEqual(published.splice(0), [{ channelId, text: 'back soon' }]); + + // With the note turned off, the announcement still says what reaches the agent. + framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + framework.closedChannelNoteSettingsExtension().reset('sol'); + + // A DM, and an open channel: every message still reaches the agent, and no note. + routing.locus = 'discord:dm:777'; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:dm:777', text: quiet }]); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + routing.locus = channelId; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published.splice(0), [{ channelId, text: quiet }]); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + + // A locus the registry doesn't know: the post fails, as before, and there's no note. + routing.locus = 'discord:g1:999999'; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + routing.locus = channelId; + + // No announcement, no note: one turned off, and explicit mode with no target this turn. + slept = await sleep({ seconds: 600, announce: false }); + assert.equal(slept.data.closedChannelNote, undefined); + framework.agents.set('sol', { proseRouting: 'explicit' }); + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published, [], 'nothing was posted'); + // Explicit mode announces to the turn's target, with the note. + routing.pin = channelId; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); +}); From a0eb0d07680c123e2b75bcafffd105fcf32ed80d Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Fri, 16 Oct 2026 14:20:52 -0400 Subject: [PATCH 15/37] fix(channels): af-speak5's round on the closed-channel note af-speak5 (wf-1738) read 89dd16dd..dbd92108: 44 findings in 19 issues, audit clean. 15 fix-now; I1 split (the send_dm exemption is Ada's, #111061); I12 and I19 intended; I14 not real (discord-mcpl requests GuildMessageReactions and not DirectMessageReactions). Ada-1017's calls: room-203 #113072. - The note alone (explicit mode's turn-end line) goes through framework.addMessage with forAgent, as recordChannelAutoOpenNotice does: held while the agent has a turn alive and written at that turn's next tool boundary (where it is injected into the live stream) or its end. The tool-ended path's streamId guard comes out: it dropped the note for good when a new stream had started (I5), the complete path never had it (I2), and it passed in the gap between a successor turn's setup and its stream (I18). Renamed appendClosedChannelNoteAlone. - A turn that ends in an error (settleAgent, stopReason 'exhausted', all seven error endings) gives the note alone for the words it left in closed channels, once its speech chain has run, in any routing mode (I7). main opened the channel at delivery however the turn ended. main's [delivered] line is unchanged. - A conversation agent gets no note (I15, Ada's call): channel_open is refused to it and its settings end with its conversation. - Sleep: when whether the channel stays closed can't be read, the default words are 'messages addressed to me', true either way (I9); an empty explicit target posts nothing, as base's truthy check had it (I10); the comment says what the words say (I11). - Changelog: speech and every send but send_dm into a DM the registry doesn't know get the note; forks; error endings; where a held line goes. The docs say the same for explicit mode (I1b, I8). Tests: - send-into-closed-channel: the sleep test awaits each announcement's post through a wrapped routeSpeech instead of one setImmediate (I17), and adds an unknown locus posting nothing (I13), no locus, a throwing DM lookup (I9), a conversation agent, hybrid, prose routing off, a silent turn and an empty explicit target (I4, I10, I15); a conversation agent's send has no note (I15); channel_publish into a closed channel has no note (I16); the send_dm case's comment says #upkeep is a channel the registry doesn't know as a DM (I1b). The harness keeps conversationAgentHomes. - explicit-prose-routing: the tool-ended turn's held note is given once, after the next turn's own words (I5); a note due while the next turn's tool call runs is held (in the deferred queue, not the window) until that call returns, and the turn is shown it with the call's result (I2, I5, I18: what the addMessage route is for). Both tests that hold a post or a move release them and stop the framework in a finally, so a failing run ends instead of leaving the file's process waiting on held gates (two red-first runs hung that way until their jobs timed out); a turn whose round after a tool call fails with a non-retryable 400 still gets the note (I7); a hybrid turn's [delivered] line ends with the note (I6). - trunk-channel-routing: a whisper tagged chat:dm, by push and by channels/incoming, leaves its closed world channel without DM standing, so the note is still given there (I3, Ada's decision (1)). --- changelog.d/speaking-never-opens.breaking.md | 12 +- docs/explicit-prose-routing.md | 6 +- src/framework.ts | 60 ++++--- test/explicit-prose-routing.test.ts | 162 +++++++++++++++++-- test/send-into-closed-channel.test.ts | 90 ++++++++++- test/trunk-channel-routing.test.ts | 48 ++++++ 6 files changed, 335 insertions(+), 43 deletions(-) diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md index c8ae10df2..e74af1c2b 100644 --- a/changelog.d/speaking-never-opens.breaking.md +++ b/changelog.d/speaking-never-opens.breaking.md @@ -17,10 +17,14 @@ wake." The agent turns that note off for good with `agent_settings`' new `closed_channel_note`, which is on by default. A channel counts as a DM when the registry knows it as one (a - `channelType: 'dm'` descriptor, a `:dm:` id or a `DM: ` label), and a `send_dm`'s answer never - carries the note. A DM the registry doesn't know, such as a portal-mcpl DM, gets the note, - which is true there too. In `explicit` mode, speech still being delivered when a tool ends the - turn gets the line once it's delivered, unless the next turn has started by then. + `channelType: 'dm'` descriptor, a `:dm:` id or a `DM: ` label). Speech and every send but + `send_dm` into a DM the registry doesn't know, such as a portal-mcpl DM, get the note, which + is true there too. A `send_dm`'s answer never carries it, since the tool sends to a DM. A + conversation agent gets no note: `channel_open` is refused to it, and its settings end with + its conversation. In `explicit` mode, speech still being delivered when a tool ends the turn + gets the line once it's delivered. A turn that ends in an error, which has no `[delivered]` + line in any mode, gets the note alone. A line that comes while another turn is under way + waits for that turn's next tool call, where that turn is shown it, or for its end. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a closed guild channel used to open it, and so turn its reactions on. Now reactions to the reply arrive only where the agent diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index e0fcf68ef..dc69dd790 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -58,7 +58,11 @@ explicit mode as in the other modes. Speaking never opens a channel, so a mode has no `[delivered]` receipt, so where a turn's `>>` speech stayed in a closed channel that isn't a DM, the turn ends with a `[closed channel]` line that says so, as a send tool's answer does, unless the agent has turned that -note off (`agent_settings`' `closed_channel_note`). +note off (`agent_settings`' `closed_channel_note`). Speech still being +delivered when the turn ends gets the line once it has been delivered. A turn +that ends in an error gets it too. If another turn is under way by then, the +line waits for that turn's next tool call, where that turn is shown it, or for +its end. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index 2426af77a..8807cd5f2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4078,11 +4078,15 @@ export class AgentFramework { private static readonly CLOSED_CHANNEL_NOTE_OFF = 'To stop this note, set closed_channel_note to false with agent_settings.'; /** The closed-channel notes for an agent's words that landed in these - * channels, or none when they've turned the note off. The note is advice: - * if working it out fails, the words were still delivered, so that's - * logged and nothing is added. */ + * channels, or none when they've turned the note off. A conversation agent + * gets none: channel_open is refused to it and its settings end with its + * conversation, so neither what the note offers nor turning it off is its + * own, and the resident that owns the channel gets the note for its own + * words. The note is advice: if working it out fails, the words were still + * delivered, so that's logged and nothing is added. */ private closedChannelNotes(agentName: string, channelIds: Iterable, serverId?: string): string[] { try { + if (this.conversationAgentHomes.has(agentName)) return []; const notes = [...channelIds].flatMap((id) => this.closedChannelNoteFor(id, serverId ?? this.channelRegistry?.getChannelServerId(id) ?? null) ?? []); // The setting is read only when there's a note to give. @@ -5919,6 +5923,15 @@ export class AgentFramework { // The identity check keeps a settle that carries an earlier turn's // object from closing a newer turn; keep it when editing call sites. if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName); + // A turn that ends in an error has no `[delivered]` receipt in any mode, + // but its words that landed in a closed channel stayed there, where they + // used to open it however the turn ended. So once its speech has run, + // the note comes alone, once per channel, as at the other endings. + const settling = outcome.stopReason === 'exhausted' && closes ? this.agents.get(agentName) : undefined; + if (settling && closes) { + void (this.proseSpeechChains.get(agentName) ?? Promise.resolve()) + .then(() => this.appendClosedChannelNoteAlone(settling, closes)); + } const run = this.ephemeralRuns.get(agentName); if (!run) return; run.lastActivity = Date.now(); @@ -10757,24 +10770,29 @@ export class AgentFramework { } /** - * Explicit mode's counterpart at logical turn end. It has no `[delivered]` - * receipt, since the agent named every destination itself. Its `>>` speech - * opened a closed channel until speaking never opened one, so where that - * speech stayed in a closed channel that isn't a DM, the turn ends with the - * closed-channel note alone, in a send's words. No-op otherwise. + * The closed-channel note alone, in a send's words, for the words a logical + * turn left in closed channels where no `[delivered]` receipt carries it: + * explicit mode's turn end, which has no receipt since the agent named every + * destination itself, and a turn that ends in an error, which has none in + * any mode. That speech opened a closed channel until speaking never opened + * one, so the note goes wherever it stayed in a closed channel that isn't a + * DM, once per channel. It goes through addMessage, as the policy's open + * notice does: held while the agent has a turn alive and written at that + * turn's next boundary, so it reaches the agent once and never mid-turn. + * No-op otherwise. */ - private appendExplicitClosedChannelNote(agent: Agent, routing: TurnRouting): void { + private appendClosedChannelNoteAlone(agent: Agent, routing: TurnRouting): void { const delivered = new Set(routing.deliveries); routing.deliveries = []; const notes = this.closedChannelNotes(agent.name, delivered); if (notes.length === 0) return; try { - const mid = agent.getContextManager().addMessage( + this.addMessage( 'user', [{ type: 'text', text: `[closed channel] ${notes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }], { system: true, kind: 'delivery-receipt' } as MessageMetadata, + { forAgent: agent.name }, ); - this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); } catch (err) { console.error('closed-channel note append failed:', err); } @@ -11254,7 +11272,7 @@ export class AgentFramework { try { const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); - // For the turn's closed-channel note (appendExplicitClosedChannelNote). + // For the turn's closed-channel note (appendClosedChannelNoteAlone). this.recordProseDelivery(routing, result); if (result?.delivered) { this.proseBounceStreaks.delete(name); @@ -13093,7 +13111,7 @@ export class AgentFramework { if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent, streamRouting); } else if (!trigger?.suppressProse) { - this.appendExplicitClosedChannelNote(agent, streamRouting); + this.appendClosedChannelNoteAlone(agent, streamRouting); } // A `!` continuation this turn asked for. This gives prose the @@ -13266,11 +13284,8 @@ export class AgentFramework { this.appendProseDeliveryReceipt(agent, streamRouting); } else if (cancelKind === 'turn_ended' && !trigger?.suppressProse) { // Explicit mode doesn't hold the turn for its deliveries, so - // the note comes once they've run, unless a new stream has - // started by then: nothing is added to a window mid-stream. - void turnSpeechChain.then(() => { - if (agent.streamId === myStreamId) this.appendExplicitClosedChannelNote(agent, streamRouting); - }); + // the note comes once they've run. + void turnSpeechChain.then(() => this.appendClosedChannelNoteAlone(agent, streamRouting)); } // A `!` an earlier round asked for still starts its // continuation when a tool ends the turn, once the turn's @@ -18548,7 +18563,7 @@ export class AgentFramework { } else if (agent?.proseRouting === 'explicit') { // Explicit mode: announce to the turn's sticky prose target if the // model has set one; otherwise stay quiet — never guess a channel. - destination = this.turnRouting.get(agentName)?.pin; + destination = this.turnRouting.get(agentName)?.pin || undefined; if (!destination) { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } @@ -18557,10 +18572,12 @@ export class AgentFramework { } } // Speaking never opens a channel, so in a closed one that isn't a DM the - // announcement says that only messages addressed to the agent reach it, + // announcement says the agent will still see messages addressed to it, // and the result carries the closed-channel note, as a send's answer does. // Both are decided from the channel as the announcement goes out: neither - // depends on the post landing, which stays fire-and-forget. + // depends on the post landing, which stays fire-and-forget. Those words + // are true in an open channel too, so they're the ones used when the + // channel's state can't be read. let closedChannelNote: string | undefined; let staysClosed = false; if (destination && this.channelRegistry?.getDescriptor(destination)) { @@ -18568,6 +18585,7 @@ export class AgentFramework { try { staysClosed = this.closedChannelNoteFor(destination, null) !== undefined; } catch (err) { + staysClosed = true; console.error(`[sleep] ${agentName}: whether ${destination} stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 00385d682..012537d3b 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1420,7 +1420,7 @@ describe('explicit prose routing', () => { /** A registry stub for the closed-channel note: #cafe is closed, laria's DM * is closed and the registry knows it as a DM, and #alpha is open. */ - function closedNoteRegistry(framework: AgentFramework, held?: Promise) { + function closedNoteRegistry(framework: AgentFramework, held?: Promise | ((locus: string | null) => Promise | undefined)) { const routed: Array<{ text: string; locus: string | null }> = []; const known: Record = { '#alpha': { channelId: 'chan-alpha', label: '#alpha' }, @@ -1432,7 +1432,8 @@ describe('explicit prose routing', () => { resolveProseTarget: (spec: string) => known[spec] ?? { error: `no channel matches "${spec}"` }, routeSpeech: async (_agent: string, text: string, locus?: string | null) => { routed.push({ text, locus: locus ?? null }); - if (held) await held; + const wait = typeof held === 'function' ? held(locus ?? null) : held; + if (wait) await wait; return { delivered: true, channelId: locus ?? '' }; }, resolveLocus: () => 'chan-alpha', @@ -1501,7 +1502,7 @@ describe('explicit prose routing', () => { await framework.stop(); }); - it("a tool-ended turn's note isn't added once the next turn's stream has started", async () => { + it("a tool-ended turn's note waits while the next turn runs, and is written once it ends", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#cafe heading out now' }, { type: 'tool_use', id: 'e1', name: 'robot--move', input: { dir: 'out' } }, @@ -1518,23 +1519,156 @@ describe('explicit prose routing', () => { // The next turn answers with nothing to deliver. feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + // A failure lets the held post go and stops the framework, so the file still ends. + try { + trigger(framework); + await framework.runUntilIdle(); + assert.equal(module.calls.length, 1, 'the move ended the first turn while its speech was still being delivered'); + trigger(framework); + // The next turn's end waits behind the held post, so it can't go idle yet. + const idle = framework.runUntilIdle(); + for (const end = Date.now() + 5000; turns < 2 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(turns, 2, 'the next turn has started'); + release(); + await idle; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); + const texts = windowTexts(framework); + assert.deepEqual(texts.filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE], 'the note is given once'); + assert.ok(texts.indexOf(CAFE_NOTE) > texts.lastIndexOf('>>skip_reply'), + "it comes after the next turn's own words: that turn never saw it"); + } finally { + release(); + await framework.stop(); + } + }); + + it("a note due while the next turn's tool call runs waits for that call, and that turn is shown it", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe heading out now' }, + { type: 'tool_use', id: 'e1', name: 'robot--move', input: { dir: 'out' } }, + ] as ContentBlock[], 'tool_use')); + let releaseCafe!: () => void; + const cafe = new Promise((resolve) => { releaseCafe = resolve; }); + let releaseMove!: () => void; + const secondMove = new Promise((resolve) => { releaseMove = resolve; }); + const framework = await createFramework(); + closedNoteRegistry(framework, (locus) => (locus === 'discord:g:cafe' ? cafe : undefined)); + // The first move ends its turn, as skip_reply does; the next turn's move waits until released. + module.handleToolCall = async (call: ToolCall): Promise => { + module.calls.push(call); + if (module.calls.length === 1) return { success: true, endTurn: true, data: { ok: true } }; + await secondMove; + return { success: true, data: { ok: true } }; + }; + let turns = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type !== 'inference:started' || ++turns !== 2) return; + // The next turn moves back, then has nothing more to say. + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'e2', name: 'robot--move', input: { dir: 'back' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }] as ContentBlock[])); + }); + const deferred = () => (framework as unknown as { deferredMessages: Array<{ content: Array<{ text?: string }> }> }) + .deferredMessages.filter((m) => m.content.some((b) => b.text === CAFE_NOTE)); + + // A failure lets the held post and move go and stops the framework, so the file still ends. + try { + trigger(framework); + await framework.runUntilIdle(); + assert.equal(module.calls.length, 1, 'the move ended the first turn while its speech was still being delivered'); + trigger(framework); + const idle = framework.runUntilIdle(); + for (const end = Date.now() + 5000; module.calls.length < 2 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(module.calls.length, 2, "the next turn's move is running"); + releaseCafe(); + for (const end = Date.now() + 5000; deferred().length === 0 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(deferred().length, 1, 'the note is due, and held'); + assert.ok(!windowTexts(framework).includes(CAFE_NOTE), "it isn't written while that turn's move runs"); + releaseMove(); + await idle; + + assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE], 'the note is given once'); + const shown = membrane.lastStream!.receivedToolResultOptions + .flatMap((options) => options?.injectedMessages ?? []) + .some((message) => (message.content as Array<{ text?: string }>).some((block) => block.text === CAFE_NOTE)); + assert.ok(shown, "the next turn is shown it with its move's result"); + } finally { + releaseCafe(); + releaseMove(); + await framework.stop(); + } + }); + + it('a turn that fails after its speech stayed in a closed channel still gets the note', async () => { + // Its speech is delivered at the tool call, and the round after the tool + // fails for good instead of completing: a 400, which the framework doesn't retry. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe heading out now' }, + { type: 'tool_use', id: 'f1', name: 'robot--move', input: { dir: 'out' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework(); + const routed = closedNoteRegistry(framework); + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + streams++; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete') { + yield { + type: 'error' as const, + error: new MembraneError({ + type: 'invalid_request', retryable: false, httpStatus: 400, + message: 'synthetic invalid request', rawError: { status: 400 }, rawRequest: request, + }), + }; + return; + } + yield event; + } + }; + return stream; + }; + trigger(framework); await framework.runUntilIdle(); - assert.equal(module.calls.length, 1, 'the move ended the first turn while its speech was still being delivered'); - trigger(framework); - // The next turn's end waits behind the held post, so it can't go idle yet. - const idle = framework.runUntilIdle(); - for (const end = Date.now() + 5000; turns < 2 && Date.now() < end;) { + for (const end = Date.now() + 5000; !windowTexts(framework).includes(CAFE_NOTE) && Date.now() < end;) { await new Promise((resolve) => setTimeout(resolve, 5)); } - assert.equal(turns, 2, 'the next turn has started'); - release(); - await idle; - await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(streams, 1, 'the failure was not retried'); + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); + assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE]); + await framework.stop(); + }); + + it("in hybrid mode, speech that stays in a closed channel ends the turn's delivery line with the note", async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#cafe hello there' }, + ] as ContentBlock[])); + const framework = await createFramework('hybrid'); + const routed = closedNoteRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); - assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [], - 'nothing is added to the window after another stream has started'); + const delivered = windowTexts(framework).filter((t) => t.startsWith('[delivered]')); + assert.equal(delivered.length, 1, delivered.join('\n')); + assert.ok(delivered[0]!.endsWith(` · ${CAFE_NOTE.slice('[closed channel] '.length)}`), delivered[0]); + assert.ok(!windowTexts(framework).some((t) => t.startsWith('[closed channel]')), 'the delivery line carries it'); await framework.stop(); }); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index ea1fab5dd..e5e2a4aaa 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -95,6 +95,8 @@ async function harness() { setStateJson: (_id: string, value: unknown) => { frameworkState = value; }, }; framework.agents = new Map([['sol', {}]]); + // Conversation agents by their home channel; sol isn't one. + framework.conversationAgentHomes = new Map(); const windowMessages: string[] = []; framework.addMessage = (_role: string, content: Array<{ text?: string }>) => { windowMessages.push(content.map((b) => b.text ?? '').join('')); @@ -234,7 +236,8 @@ test('a send into an open channel, a DM the registry knows, or by send_dm carrie assert.ok(!(await reply()).data.includes('[closed channel]'), 'open'); } { - // send_dm sends to a DM, whatever the registry knows of it. + // send_dm sends to a DM by what the tool is, so its answer carries no + // note even into #upkeep, a channel the registry doesn't know as a DM. const { reply } = await harness(); assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); } @@ -445,7 +448,17 @@ test("a sleep announced into a closed channel says only what addresses the agent framework.activeTurnTriggers = new Map(); framework.timeZone = 'UTC'; const routing = framework.turnRouting.get('sol'); - /** One sleep call, as the agent makes it; its result once the posts it started have run. */ + // Where each announcement was routed, and its post, which the sleep doesn't wait for. + const routedTo: Array = []; + const posts: Promise[] = []; + const route = registry.routeSpeech.bind(registry); + registry.routeSpeech = ((...args: Parameters) => { + routedTo.push(args[2] as string | null); + const post = route(...args); + posts.push(post); + return post; + }) as typeof route; + /** One sleep call, as the agent makes it; its result once the post it started has run. */ const sleep = async (input: Record) => { const result = await new Promise<{ success: boolean; data: Record; endTurn?: boolean }>((resolve) => { framework.pushEvent = (event: { type: string; callId?: string; result: never }) => { @@ -453,7 +466,7 @@ test("a sleep announced into a closed channel says only what addresses the agent }; framework.dispatchSleepToolCall('sol', { id: 'call-sleep', name: 'sleep', input }); }); - await new Promise((resolve) => setImmediate(resolve)); + await Promise.allSettled(posts.splice(0)); return result; }; const quiet = "💤 Going quiet for 10m. I'll still see messages, but won't respond until I wake."; @@ -495,14 +508,62 @@ test("a sleep announced into a closed channel says only what addresses the agent routing.locus = 'discord:g1:999999'; slept = await sleep({ seconds: 600 }); assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(routedTo.splice(0).slice(-1), ['discord:g1:999999']); + assert.deepEqual(published.splice(0), [], 'nothing reached a channel'); + // No locus at all: routed as before, with nothing to post to, and no note. + routing.locus = null; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(routedTo.splice(0), [null]); + assert.deepEqual(published.splice(0), []); routing.locus = channelId; + // When whether the channel stays closed can't be read, the words are ones + // true either way, and there's no note. + const isDm = registry.isDirectMessageChannel.bind(registry); + registry.isDirectMessageChannel = () => { throw new Error('the registry is unreadable'); }; + slept = await sleep({ seconds: 600 }); + registry.isDirectMessageChannel = isDm; + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + + // A conversation agent can't open the channel, so it gets no note; the words + // for people follow the channel. + framework.conversationAgentHomes.set('sol', channelId); + slept = await sleep({ seconds: 600 }); + framework.conversationAgentHomes.delete('sol'); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + + // Hybrid routing announces to the locus as locus mode does. + framework.agents.set('sol', { proseRouting: 'hybrid' }); + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + routedTo.splice(0); + + // Prose routing off, or a silent turn: nothing is posted, and no note. + framework.agents.set('sol', { proseRouting: 'disabled' }); + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + framework.agents.set('sol', {}); + framework.activeTurnTriggers.set('sol', { suppressProse: true }); + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + framework.activeTurnTriggers.delete('sol'); + assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); + // No announcement, no note: one turned off, and explicit mode with no target this turn. slept = await sleep({ seconds: 600, announce: false }); assert.equal(slept.data.closedChannelNote, undefined); framework.agents.set('sol', { proseRouting: 'explicit' }); slept = await sleep({ seconds: 600 }); assert.equal(slept.data.closedChannelNote, undefined); + // An empty target is none. + routing.pin = ''; + slept = await sleep({ seconds: 600 }); + assert.equal(slept.data.closedChannelNote, undefined); + assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); assert.deepEqual(published, [], 'nothing was posted'); // Explicit mode announces to the turn's target, with the note. routing.pin = channelId; @@ -510,3 +571,26 @@ test("a sleep announced into a closed channel says only what addresses the agent assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); }); + +test('a conversation agent gets no note on its send: channel_open is refused to it', async () => { + const { framework, reply, windowMessages } = await harness(); + framework.conversationAgentHomes.set('sol', channelId); + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(!data.includes('[closed channel]'), data); + assert.deepEqual(windowMessages.filter((m) => m.startsWith('[closed channel]')), []); +}); + +test("channel_publish never opened a channel, so its answer carries no note", async () => { + const { framework, published, windowMessages } = await harness(); + const result = await new Promise<{ success: boolean; data?: unknown }>((resolve) => { + framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown } }) => { + if (event.type === 'tool-result' && event.callId === 'call-publish') resolve(event.result); + }; + framework.dispatchChannelToolCall('sol', { id: 'call-publish', name: 'channel_publish', input: { channelId, content: 'a word to #upkeep' } }); + }); + assert.equal(result.success, true); + assert.deepEqual(published, [{ channelId, text: 'a word to #upkeep' }]); + assert.ok(!JSON.stringify(result.data ?? '').includes('[closed channel]')); + assert.deepEqual(windowMessages.filter((m) => m.startsWith('[closed channel]')), []); +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index e754b1034..8ee5fbd1b 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -47,6 +47,7 @@ function internals(framework: AgentFramework) { channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; handleMcplChannelIncoming(event: unknown): Promise; + closedChannelNotes(agentName: string, channelIds: Iterable): string[]; }; } @@ -392,6 +393,53 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it("a whisper's chat:dm tag doesn't make its channel a DM: words left in that closed channel still get the note", async () => { + // A chat:dm tag describes one message. eidoverse-worlds tags a whisper inside + // its shared world channel that way, on either delivery path. + const framework = await makeFramework(); + const i = internals(framework); + const world = 'eido:world:plaza'; + i.channelRegistry = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + isDirectMessageChannel: () => false, + getDesiredState: () => undefined, + getDescriptor: () => ({ label: '#plaza', capabilities: { history: { maxMessages: 80 } } }), + getChannelServerId: () => 'eido', + proseTargetFor: () => '#plaza', + stopAll: () => {}, + }; + const whisper = ['chat:dm', 'chat:private', 'chat:addressed']; + i.handleMcplPushEvent({ + type: 'mcpl:push-event', + serverId: 'eido', + featureSet: 'eido.chat', + eventId: 'eido_w1', + content: [{ type: 'text', text: 'mira (whispering): over here' }], + origin: { source: 'eido', messageId: 'w1', mcplChannelId: world, authorName: 'mira' }, + tags: whisper, + timestamp: new Date().toISOString(), + inferenceId: 'iw1', + triggerInference: false, + }); + await i.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', + serverId: 'eido', + channelId: world, + messageId: 'w2', + author: { id: 'U9', name: 'mira' }, + content: [{ type: 'text', text: 'mira (whispering): and again' }], + timestamp: new Date().toISOString(), + metadata: {}, + tags: whisper, + triggerInference: false, + }); + const notes = i.closedChannelNotes('scout', [world]); + assert.equal(notes.length, 1, 'the note is still given there'); + assert.match(notes[0]!, /^#plaza \(eido:world:plaza\) stays closed/); + await framework.stop(); + }); + it('a channel-incoming trunk turn records its triggering channel', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); From 28d55e2e036d4459c4cfeceffc3de8f53b9b97d4 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Fri, 16 Oct 2026 23:57:39 -0400 Subject: [PATCH 16/37] fix(channels): speech keeps only the agent's own close; it opens every other closed channel, as before (R1-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anarchid's review of a0eb0d0 (agent-framework#330, R1) found the rule broader than the problem: the friction was the agent's own close being undone when it spoke, and "speaking never opens" also changed every DM and first reply to a mention. Ada-1017 chose the narrow rule (room-203 #120085, #120113): - Speech (routeSpeech) and a successful send tool open a closed channel as on main, unless the agent closed or declined it itself (agent-tool, invitation-declined): ChannelRegistry.openForSpeech. A tune-out stays untouched (the transport is open), and a channel decided open whose transport is down is reconciliation's, so its decision keeps its source. main's "[channels] Now open" notice and onChannelAutoOpened's delivery reports come back with the opens; its last sentence keeps #330's wording, now true for every cause. - The agent's close or decline replaces a machine's close too, not only a pure default (channel-registry.ts setDesiredState), or speech would reopen a channel the agent closed after a machine did. - A machine's close of a channel with no record records 'default-closed' (#417, R1-6): a machine's close isn't a decision, so policy can still lift it. - A failed open no longer stops the words: they publish, as on a server without channels.lifecycle. R1-4: a publish that throws is a failed route (fail('publish failed: …')), so the agent is told. - The answered mark goes: noteAnswered, notePushDelivered, decisionSequence, the coalescer's accepted hook, the `answered` record field, and answered-channel-fallback.test.ts. So does isDirectMessageChannel. - The closed-channel note stays only where the narrow rule changes what a resident meets: words into a channel it closed or declined itself. Send answer, [delivered] line, and explicit mode's turn-end line through addMessage (Ada: keep). "you closed it" / "you declined it"; no channel_open clause where the server's grant lacks channels.lifecycle (R1-5). The settleAgent error-ending hook and the note in the sleep result go (Ada); the sleep announcement's words stay for a channel the agent closed. - The invitation's no-target option drops the follow-up sentence #330 had copied into it: replying opens a channel nobody closed, so follow-ups reach the agent there. - Changelog fragments renamed agent-close-holds.{breaking,fixed}.md and rewritten; docs/explicit-prose-routing.md says the narrow rule. Tests: routing (31: delivery opens; a failed open still sends; a publish that throws fails the route; the agent's close and decline stand against speech; a machine's close doesn't, and the agent's close over it does; openForSpeech's cases; #417), send-into-closed-channel (14: the note in a channel the agent closed; a send into one nobody decided about opens it and says so; the in-flight close stands; sleep words), explicit (69), trunk-channel-routing (the whisper test goes with the DM rule), mcpl-rejection-log-lines back to main's, present-while-acting's stub. --- changelog.d/agent-close-holds.breaking.md | 41 ++ changelog.d/agent-close-holds.fixed.md | 24 ++ changelog.d/speaking-never-opens.breaking.md | 79 ---- changelog.d/speaking-never-opens.fixed.md | 9 - docs/explicit-prose-routing.md | 20 +- src/framework.ts | 227 ++++++----- src/mcpl/channel-registry.ts | 286 +++++++------- src/mcpl/push-coalescer.ts | 9 - test/answered-channel-fallback.test.ts | 126 ------ test/channel-registry-routing.test.ts | 380 +++++++++++-------- test/explicit-prose-routing.test.ts | 59 +-- test/mcpl-rejection-log-lines.test.ts | 14 +- test/present-while-acting.test.ts | 7 +- test/send-into-closed-channel.test.ts | 230 ++++++----- test/trunk-channel-routing.test.ts | 56 +-- 15 files changed, 671 insertions(+), 896 deletions(-) create mode 100644 changelog.d/agent-close-holds.breaking.md create mode 100644 changelog.d/agent-close-holds.fixed.md delete mode 100644 changelog.d/speaking-never-opens.breaking.md delete mode 100644 changelog.d/speaking-never-opens.fixed.md delete mode 100644 test/answered-channel-fallback.test.ts diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md new file mode 100644 index 000000000..d4c85ad3d --- /dev/null +++ b/changelog.d/agent-close-holds.breaking.md @@ -0,0 +1,41 @@ +- **Residents and hosts:** an agent's own `channel_close` or `channel_decline` now holds against + its own speech there. A send tool's call (`send_message`, `reply_message`, `send_dm`, + `send_file`, `send_files`) or plain speech into a channel the agent closed or declined publishes + there and leaves the channel closed. Before, it opened the channel, recorded that open over the + agent's close, and announced "[channels] Now open: … your own message into the closed channel + engaged it". Speech into any other closed channel, one nobody decided about (such as a DM that + woke the agent) or one a machine closed, opens it as before, with that notice. + - **Who needs to act:** an agent that closed a channel and wants its ongoing traffic again opens + it with `channel_open`; speaking there no longer does. Messages that address the agent still + arrive while a channel is closed. + - **What residents read:** where an agent's words land in a channel it closed or declined, the + send tool's answer, for routed speech the turn's `[delivered]` line, or in `explicit` routing + mode, which has no such line, a line of its own at the turn's end, says that the channel stays + closed, that messages addressing the agent still reach it, and that `channel_open` opens it. + The last clause is left out on a server whose grant lacks `channels.lifecycle`, where no + channel can open. In `explicit` mode, speech still being delivered when a tool ends the turn + gets the line once it's delivered, and a line that comes while another turn is under way + waits for that turn's next tool call, where that turn is shown it, or for its end. The agent + turns the note off for good with `agent_settings`' new `closed_channel_note`, which is on by + default. A `send_dm`'s answer never carries it, since the tool sends to a DM, and a + conversation agent gets none: `channel_open` is refused to it, and its settings end with its + conversation. In such a channel, the `sleep` tool's default announcement says "I'll still see + messages addressed to me, but won't respond until I wake." + - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction + forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to + reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the + agent opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: + discord-mcpl requests no DM reaction events. + - **What hosts notice:** on discord-mcpl, a channel the agent closed keeps delivering the + messages that address the agent as push events, as any closed channel's are, where a reply + used to reopen it and switch them to `channels/incoming`. Host code that acts on + `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. +- **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. + `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went + to, from a composite or raw id, and opens nothing. `openForSpeech(serverId, channelId, source)` + opens it as the framework's send path and `routeSpeech` do: a closed channel opens unless the + agent closed or declined it, it's tuned out, or it's decided open with its transport down. + `closedByAgent(serverId, channelId)` and `canOpenChannels(serverId)` are new. +- **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so + the first request after the upgrade that carries those tools misses the cache from its tool + block on. diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md new file mode 100644 index 000000000..d5d26fd36 --- /dev/null +++ b/changelog.d/agent-close-holds.fixed.md @@ -0,0 +1,24 @@ +- A failed open no longer stops the agent's speech. Speech into a closed channel whose open fails, + on a server whose grant lacks `channels.lifecycle` or on a dead transport, is still published. + Before, the open that preceded publishing threw, and the speech failed with "locus channel is + closed and open failed". A send tool's call was already published before its open. +- A publish that throws, as on a dead transport, is now a failed route. The agent gets the + `[discord-send-failed]` marker, and `mcpl:speech-route-failed` is traced. Before, the framework + only logged it. +- A send tool's call into a tuned-out channel no longer ends the tune-out. It used to set the + channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". +- The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which + is closed by default, or of one a machine closed, is now recorded as its decision. Before, the + channel kept its default or the machine's source, so subscription policy or a server's + `initiallyOpen` could open the first later. A machine close of a channel nobody decided about, + such as connectome-host's `subscription-gc`, still records no decision: the channel keeps its + default, so policy can still open it. That now holds for a channel with no record at all, one + registered lazily or on a server whose grant lacks `channels.lifecycle`, where a machine close + used to record its own source, which policy never lifts (#417). +- `channel_close`'s description and the "[channels] Now open" notice's last sentence now say that + a channel the agent closes stays closed until it opens it with `channel_open`, and that neither + speaking there nor policy reopens it. +- The closed-channel invitation is no longer added to a direct message (`chat:dm`), all of which + reaches the agent whether the DM is open or not. Where no single-word target reaches a channel, + it no longer says that replying needs a join: it suggests checking whether the server has a + send tool that can address the channel. diff --git a/changelog.d/speaking-never-opens.breaking.md b/changelog.d/speaking-never-opens.breaking.md deleted file mode 100644 index e74af1c2b..000000000 --- a/changelog.d/speaking-never-opens.breaking.md +++ /dev/null @@ -1,79 +0,0 @@ -- **Residents and hosts:** speaking never opens a channel. A send tool's call (`send_message`, - `reply_message`, `send_dm`, `send_file`, `send_files`) or plain speech into a registered channel - that is closed publishes there and leaves the channel closed, with its recorded decision - unchanged. Before, it opened the channel, recorded that open over the agent's own - `channel_close`, and announced "[channels] Now open: … your own message into the closed channel - engaged it". - - **Who needs to act:** an agent that wants a channel's ongoing traffic after speaking there - opens it with `channel_open`; speaking no longer does. Messages that address the agent still - arrive while a channel is closed. - - **What residents read:** where an agent's words land in a closed channel that isn't a DM, the - send tool's answer, for routed speech the turn's `[delivered]` line, or in `explicit` routing - mode, which has no such line, a line of its own at the turn's end, says that the channel stays - closed, that messages addressing the agent still reach it, and that `channel_open` opens it. - So does the `sleep` tool's result (`closedChannelNote`) when its announcement goes to such a - channel, decided from the channel as the announcement goes out, and that announcement's - default words there become "I'll still see messages addressed to me, but won't respond until I - wake." - The agent turns that note off for good with `agent_settings`' new `closed_channel_note`, which - is on by default. A channel counts as a DM when the registry knows it as one (a - `channelType: 'dm'` descriptor, a `:dm:` id or a `DM: ` label). Speech and every send but - `send_dm` into a DM the registry doesn't know, such as a portal-mcpl DM, get the note, which - is true there too. A `send_dm`'s answer never carries it, since the tool sends to a DM. A - conversation agent gets no note: `channel_open` is refused to it, and its settings end with - its conversation. In `explicit` mode, speech still being delivered when a tool ends the turn - gets the line once it's delivered. A turn that ends in an error, which has no `[delivered]` - line in any mode, gets the note alone. A line that comes while another turn is under way - waits for that turn's next tool call, where that turn is shown it, or for its end. - - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction - forwarding, and closing it turns that off. A reply in a closed guild channel used to open - it, and so turn its reactions on. Now reactions to the reply arrive only where the agent - opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: - discord-mcpl requests no DM reaction events. - - **What hosts notice:** on discord-mcpl, an answered channel's later messages that address - the agent arrive as push events, as any closed channel's do, instead of `channels/incoming`. - Host code that acts on `channels/incoming`, such as connectome-host's outage reply and typing - hints, doesn't see them. Where a turn with no trigger (a heartbeat) speaks still follows the - agent: a message pushed from a channel it answered while the channel was closed moves the - most recent inbound channel, as an open channel's `channels/incoming` does. That holds across - a restart, until the next decision about the channel (an open, a close, a decline or a - tune-out). A decision made while the answer is still in flight, such as a close in the same - round, stands too: the answer marks nothing. For a DM, every message of which reaches the - agent, that is as before. An answered guild channel's messages that don't address the agent no - longer arrive, so only those that do move it. A push from a closed channel the agent never - answered moves nothing, as before. - - **What else changes:** the subscription-policy notice's last sentence and `channel_close`'s - description now say that a channel the agent closes stays closed until it opens it with - `channel_open`, and that neither speaking there nor policy reopens it. The closed-channel - invitation is no longer added to a direct message (`chat:dm`), all of which reaches the - agent whether the DM is open or not; a DM the agent answers now stays closed, so the - invitation would otherwise repeat on every DM. A whisper tagged `chat:dm` inside a shared - channel that is closed carries none either. Where no single-word target reaches a channel, - the invitation no longer says that replying needs a join: it suggests checking whether the - server has a send tool that can address the channel, and says that follow-ups reach the - agent only when they address it. A send no longer re-runs the open of a channel decided open - whose transport is down: `channel_open` does that, and so does the next registration's - reconciliation for a channel its server registers, as for a channel nobody speaks in. - - **Unchanged:** typing indicators, which never depended on a channel being open. A registered - channel with a stored decision to open, including one that an earlier send opened, - reconciles open at its next registration, as before. A DM known only from push events is - never reconciled: one that an earlier send opened used to reopen on the agent's next reply - after a restart, and now stays closed until the agent opens it. -- **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. - `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went - to, from a composite or raw id, and opens nothing. `decisionSequence()`, - `noteAnswered(channelId, serverId, decidedSince)` and - `notePushDelivered(serverId, channelId, messageId?, threadId?, tags?)` are new: `routeSpeech` - and the framework's send path read the first before they publish and call the second when the - agent answers a closed channel, and the framework's push lane calls the third when it accepts - a push. A module's `channel_close` with an unknown machine source is now refused even when the - channel is already closed. The coalescer's host can observe each occurrence it admits afresh - (`accepted`). - `onChannelAutoOpened` reports only `source: 'subscription-policy'`, and its `conversationId` is - gone. The `mcpl:channel-opened-by-send` and `mcpl:channel-open-failed` traces are no longer - emitted. A `desired-state` record in the channel lifecycle log can carry `answered: true`; - earlier versions ignore the field. `isDirectMessageChannel(channelId)` is new, and says whether - the registry knows a channel as a DM. -- **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so - the first request after the upgrade that carries those tools misses the cache from its tool - block on. diff --git a/changelog.d/speaking-never-opens.fixed.md b/changelog.d/speaking-never-opens.fixed.md deleted file mode 100644 index ab31c1095..000000000 --- a/changelog.d/speaking-never-opens.fixed.md +++ /dev/null @@ -1,9 +0,0 @@ -- A send tool's call into a tuned-out channel no longer ends the tune-out. It used to set the - channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". -- Speech into a closed channel on a server whose grant lacks `channels.lifecycle` now publishes. - Before, the open that preceded publishing threw, and the speech failed. -- The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which - is closed by default, is now recorded as its decision. Before, the channel kept its default, - so subscription policy or a server's `initiallyOpen` could open it later. A machine close of - such a channel, such as connectome-host's `subscription-gc`, still records no decision: the - channel keeps its default, so policy can still open it. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index dc69dd790..00b55f99c 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -53,16 +53,16 @@ agent (then notices append without waking, breaking any loop). - The entire "which event may move the agent's voice" question. Send tools (`send_message` etc.) and channel lifecycle work the same in -explicit mode as in the other modes. Speaking never opens a channel, so a -`>>` send into a closed channel publishes there and leaves it closed. Explicit -mode has no `[delivered]` receipt, so where a turn's `>>` speech stayed in a -closed channel that isn't a DM, the turn ends with a `[closed channel]` line -that says so, as a send tool's answer does, unless the agent has turned that -note off (`agent_settings`' `closed_channel_note`). Speech still being -delivered when the turn ends gets the line once it has been delivered. A turn -that ends in an error gets it too. If another turn is under way by then, the -line waits for that turn's next tool call, where that turn is shown it, or for -its end. +explicit mode as in the other modes. A `>>` send into a closed channel opens +it, with the usual `[channels]` notice, unless the agent closed or declined +that channel itself: then it publishes there and leaves it closed. Explicit mode has no `[delivered]` +receipt, so where a turn's `>>` speech stayed in a channel the agent closed or +declined, the turn ends with a `[closed channel]` line that says so, as a send +tool's answer does, unless the agent has turned that note off +(`agent_settings`' `closed_channel_note`). Speech still being delivered when +the turn ends gets the line once it has been delivered. If another turn is +under way by then, the line waits for that turn's next tool call, where that +turn is shown it, or for its end. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index 8807cd5f2..01c8f0f09 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -236,9 +236,10 @@ function estimateAppendedRoundTokens( /** * Explicit delivery tools. A successful call records the channel it - * delivered into in the sending turn's engaged set and opens nothing: - * speaking never opens a channel. Bare tool names, matched after stripping - * the MCPL server prefix. + * delivered into in the sending turn's engaged set, and opens it if it's + * closed and the agent didn't close or decline it itself (send implies + * engagement — see ChannelRegistry.openForSpeech). Bare tool names, matched + * after stripping the MCPL server prefix. */ const SEND_ENGAGEMENT_TOOLS = new Set([ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files', @@ -4033,7 +4034,7 @@ export class AgentFramework { properties: { closed_channel_note: { type: 'boolean', - description: "Add a note when your words go to a closed channel that isn't a DM, saying that it stays closed, " + + description: 'Add a note when your words go to a channel you closed or declined, saying that it stays closed, ' + 'what still reaches you from it, and how to open it. On by default. Persists across turns and restarts until changed.', }, }, @@ -4047,31 +4048,33 @@ export class AgentFramework { }; } - /** What an agent is told when their words go to a channel that stays closed, - * or undefined where nothing needs saying: an open channel, or a channel the - * registry knows as a DM (isDirectMessageChannel), every message of which - * reaches the agent, open or not. A chat:dm tag describes one message, not - * its channel: a whisper inside a shared channel carries one too. A DM the - * registry doesn't know gets the note, which is true there as well. What a - * closed channel still delivers beyond messages that address the agent is - * the connector's (slack-mcpl delivers ambient traffic by its own - * subscription), so the note promises only those, and what channel_open - * does. One connector state the host can't see makes the second sentence - * false: a channel muted with discord-mcpl's mute_channel delivers nothing, - * messages that address the agent included. The agent that muted it knows. */ + /** What an agent is told when their words go to a channel that stays closed + * because it closed or declined the channel itself (ChannelRegistry + * closedByAgent), or undefined where nothing needs saying: speech opens + * every other closed channel, as it always did (openForSpeech), so the + * agent meets the change only here, where speaking used to reopen what it + * had closed. What a closed channel still delivers beyond messages that + * address the agent is the connector's (slack-mcpl delivers ambient + * traffic by its own subscription), so the note promises only those, and + * what channel_open does where the server's grant lets it open channels + * (channels.lifecycle). One connector state the host can't see makes the + * second sentence false: a channel muted with discord-mcpl's mute_channel + * delivers nothing, messages that address the agent included. The agent + * that muted it knows. */ private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { const registry = this.channelRegistry; - if (!registry || registry.isChannelOpen(channelId)) return undefined; - if (registry.isDirectMessageChannel(channelId)) return undefined; + if (!registry || !serverId || registry.isChannelOpen(channelId)) return undefined; + const how = registry.closedByAgent(serverId, channelId); + if (!how) return undefined; const label = registry.getDescriptor(channelId)?.label; const place = label && label !== channelId ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId; - const open = serverId - ? `channel_open with channelId "${channelId}" and serverId "${serverId}"` - : `channel_open with channelId "${channelId}"`; - return `${place} stays closed: speaking into a closed channel doesn't open it. Messages there that address you, ` + - `such as an @-mention, still reach you; ${open} opens it.`; + const reach = 'Messages there that address you, such as an @-mention, still reach you'; + const open = registry.canOpenChannels(serverId) + ? `; channel_open with channelId "${channelId}" and serverId "${serverId}" opens it.` + : '.'; + return `${place} stays closed: you ${how} it, and your words there don't open it. ${reach}${open}`; } /** How to stop the closed-channel note, said once wherever it appears. */ @@ -5923,15 +5926,6 @@ export class AgentFramework { // The identity check keeps a settle that carries an earlier turn's // object from closing a newer turn; keep it when editing call sites. if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName); - // A turn that ends in an error has no `[delivered]` receipt in any mode, - // but its words that landed in a closed channel stayed there, where they - // used to open it however the turn ended. So once its speech has run, - // the note comes alone, once per channel, as at the other endings. - const settling = outcome.stopReason === 'exhausted' && closes ? this.agents.get(agentName) : undefined; - if (settling && closes) { - void (this.proseSpeechChains.get(agentName) ?? Promise.resolve()) - .then(() => this.appendClosedChannelNoteAlone(settling, closes)); - } const run = this.ephemeralRuns.get(agentName); if (!run) return; run.lastActivity = Date.now(); @@ -9655,11 +9649,12 @@ export class AgentFramework { * * No invitation on a direct message (`chat:dm`): every message of a DM is * addressed, so it reaches the agent open or closed, and there is nothing - * to miss or join. Speaking never opens a channel, so a DM the agent has - * answered stays closed, and without this every DM would carry the block. - * The tag describes the message, not the channel: a whisper tagged - * `chat:dm` inside a shared channel that is closed carries no invitation - * either, though its channel's other traffic stays out of reach. + * to miss or join. A DM registers closed, and stays closed until the agent + * answers it, or for good once the agent closes it, so without this a DM + * would carry the block. The tag describes the message, not the channel: + * a whisper tagged `chat:dm` inside a shared channel that is closed + * carries no invitation either, though its channel's other traffic stays + * out of reach. */ private buildClosedChannelInvitation(opts: { serverId: string; @@ -9696,8 +9691,7 @@ export class AgentFramework { ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; ` + - `check whether ${opts.serverId} exposes a send tool that can address it. ` + - `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`; + `check whether ${opts.serverId} exposes a send tool that can address it.\n`; const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -9784,14 +9778,6 @@ export class AgentFramework { this.coalescingRecentReceipts.push(record); this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); }, - // A push admitted afresh, deferred included, is where a message came - // in. This hook never runs for a producer's retry, which the coalescer - // acknowledges again without delivering anything; a withdrawal never - // counts, even when its notice is shown. - accepted: (occ) => { - if (occ.event.lane !== 'push' || occ.retract) return; - this.notePushAccepted(occ.event.event, occ.scope.kind === 'channel' ? occ.scope.id : undefined); - }, wasPublished: (subject, eventId) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. @@ -9952,21 +9938,6 @@ export class AgentFramework { }); } - /** - * A push the host accepted, from the channel it belongs to (`channelId`, or - * the one its origin names): from a channel the agent answered while it was - * closed, it is where the last message came in, as an accepted - * channels/incoming message is (ChannelRegistry.notePushDelivered). - */ - private notePushAccepted(event: McplPushEvent, channelId?: string): void { - if (!this.channelRegistry) return; - const id = channelId ?? this.derivePushEventChannel(event.origin)?.channelId; - if (!id) return; - const origin = (event.origin ?? {}) as Record; - const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); - this.channelRegistry.notePushDelivered(event.serverId, id, str(origin.messageId), str(origin.threadId), event.tags); - } - /** §3.2: a channel-scoped push needs what a channels/incoming message needs. */ private assertChannelScopedPush(serverId: string, channelId: string): void { const server = this.mcplServerRegistry?.getServer(serverId); @@ -10770,16 +10741,14 @@ export class AgentFramework { } /** - * The closed-channel note alone, in a send's words, for the words a logical - * turn left in closed channels where no `[delivered]` receipt carries it: - * explicit mode's turn end, which has no receipt since the agent named every - * destination itself, and a turn that ends in an error, which has none in - * any mode. That speech opened a closed channel until speaking never opened - * one, so the note goes wherever it stayed in a closed channel that isn't a - * DM, once per channel. It goes through addMessage, as the policy's open - * notice does: held while the agent has a turn alive and written at that - * turn's next boundary, so it reaches the agent once and never mid-turn. - * No-op otherwise. + * The closed-channel note alone, in a send's words, at explicit mode's turn + * end, which has no `[delivered]` receipt since the agent named every + * destination itself. A `>>` into a channel the agent closed or declined + * used to reopen it, and now leaves it closed, so the note goes wherever the + * turn's speech stayed in such a channel, once per channel. It goes through + * addMessage, as the open notices do: held while the agent has a turn alive + * and written at that turn's next boundary, so it reaches the agent once and + * never mid-turn. No-op otherwise. */ private appendClosedChannelNoteAlone(agent: Agent, routing: TurnRouting): void { const delivered = new Set(routing.deliveries); @@ -10975,32 +10944,41 @@ export class AgentFramework { } /** - * Durable window notice for channels the agent did not ask to open: the - * subscription policy admitted them. (Speaking never opens a channel, so - * nothing the agent says lands here.) The agent must always learn that new - * traffic will start flowing, and that `channel_close` opts out (their - * decision outranks policy). System message: never triggers inference, - * never conversational, never locus-eligible. + * Durable window notice for a channel the agent did not ask to open — + * subscription-policy admission, or an open by the agent's own delivery + * into a closed channel nobody decided to keep closed. The agent must + * always learn that new traffic will start flowing, and that + * `channel_close` opts out: its close stands against policy and against + * its own speech there. System message: never triggers inference, never + * conversational, never locus-eligible. */ - private recordChannelAutoOpenNotice(channels: Array<{ channelId: string; label?: string }>): void { + private recordChannelAutoOpenNotice( + agentName: string | undefined, + channels: Array<{ channelId: string; label?: string }>, + cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', + ): void { if (channels.length === 0) return; const shown = channels .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) .join(', '); const plural = channels.length > 1; + const causeText = + cause === 'subscription-policy' + ? 'your subscription policy just admitted ' + (plural ? 'them' : 'it') + : 'your own message into the closed channel engaged it'; const text = - `[channels] Now open: ${shown} — your subscription policy just admitted ${plural ? 'them' : 'it'}. ` + + `[channels] Now open: ${shown} — ${causeText}. ` + `Ongoing traffic from ${plural ? 'these channels' : 'this channel'} will reach you. ` + `If you don't want ${plural ? 'one of them' : 'it'}, use channel_close: a channel you close ` + 'stays closed until you open it with channel_open, and neither speaking there nor policy reopens it.'; try { // Through framework.addMessage for mid-turn safety (deferral + - // hear-while-acting injection): an admission can arrive inside a live - // turn (a channels/changed mid-stream), and a direct context-manager - // append there would store the notice out of order. Targets the - // primary agent. + // hear-while-acting injection) — a delivery-forced open happens inside + // a live turn, and a direct context-manager append there would store + // the notice out of order. Targets the primary agent; connectome-host + // runs trunk-only, so this is the delivering agent in practice. this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'channel-notice' }); - console.error(`[channel] auto-open notice (subscription-policy): ${shown}`); + console.error(`[channel] auto-open notice (${cause}) -> ${agentName ?? 'primary'}: ${shown}`); } catch (err) { console.error('recordChannelAutoOpenNotice failed:', err); } @@ -16108,11 +16086,7 @@ export class AgentFramework { this.initializePushCoalescer(); this.pushHandler = new PushHandler( this.featureSetManager, - // Called once a push is accepted, its trigger filter returned. - (event) => { - this.notePushAccepted(event); - this.pushEvent(event as unknown as ProcessEvent); - }, + (event) => this.pushEvent(event as unknown as ProcessEvent), (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), @@ -16212,10 +16186,12 @@ export class AgentFramework { console.error('onRouteFailure: failed to record send-failure marker:', err); } }, - // A channel the subscription policy opened without the agent asking - // must be announced in the window, with the opt-out named. - onChannelAutoOpened: ({ channels }) => { - this.recordChannelAutoOpenNotice(channels); + // A channel opened without the agent asking (policy admission or an + // open forced by delivery) must be announced in the window, with the + // opt-out named. Routed here so registry-driven opens and framework- + // driven opens produce the same durable notice. + onChannelAutoOpened: ({ conversationId, source, channels }) => { + this.recordChannelAutoOpenNotice(conversationId, channels, source); }, }, ); @@ -18037,7 +18013,7 @@ export class AgentFramework { const startTime = Date.now(); const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; // The turn that sends: a send's engaged channel is recorded on it, even - // when the send's result comes after the turn. + // when the send's result, or the open that follows, comes after the turn. const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined; // Build state params for stateful tools (Step 8). @@ -18087,9 +18063,6 @@ export class AgentFramework { } } - // Decisions about channels taken while the call is in flight, such as the - // agent's channel_close in the same round, outrank the answer it makes. - const decidedSince = this.channelRegistry?.decisionSequence() ?? 0; server.sendToolsCall(toolName, args, stateParams) .then(async (result) => { // RFC-005: register references and eagerly fetch the small ones @@ -18117,10 +18090,9 @@ export class AgentFramework { // turn counts as conversationally addressed. Injection metadata // carries the CANONICAL channel id (descriptor form), while send args // often carry the raw provider id, so the registry resolves it first. - // The send opens nothing: speaking never opens a channel, so a closed - // channel stays closed, and its recorded decision stays as it was. - // Where a closed channel delivers only follow-ups that address the - // agent (discord-mcpl), those re-pin on their own. + // A send into a closed channel opens it, as channel_open does, unless + // the agent closed or declined it itself (openForSpeech): then it + // stays closed, and the send's answer says so. if ( this.channelRegistry && SEND_ENGAGEMENT_TOOLS.has(toolName) && @@ -18132,11 +18104,8 @@ export class AgentFramework { const engagedChannel = target ? this.channelRegistry.resolveSendTarget(target, serverId) : undefined; if (engagedChannel) { sendingTurn?.engaged.add(engagedChannel); - // A closed channel stays closed, and where a turn with no trigger - // speaks still follows the agent there. - this.channelRegistry.noteAnswered(engagedChannel, serverId, decidedSince); - // The send's answer says so, where the agent's words stayed in a - // closed channel that isn't a DM. + // Where the agent's words stayed in a channel it closed or + // declined itself, the send's answer says so. const [note] = toolName === 'send_dm' ? [] : this.closedChannelNotes(agentName, [engagedChannel], serverId); if (note) { result.content = [ @@ -18144,6 +18113,28 @@ export class AgentFramework { { type: 'text', text: `[closed channel] ${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, ]; } + // Fire-and-forget: the send already succeeded; the open must not + // delay or fail the tool result. + const registry = this.channelRegistry; + registry + .openForSpeech(serverId, engagedChannel, 'opened-by-reply') + .then((status) => { + if (status === 'opened') { + console.error( + `[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`, + ); + this.recordChannelAutoOpenNotice( + agentName, + [{ channelId: engagedChannel, label: registry.getDescriptor(engagedChannel)?.label }], + 'opened-by-reply', + ); + } else if (status === 'open-failed') { + console.error( + `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed — channel remains half-alive, will reconcile`, + ); + } + }) + .catch((err) => console.error('[channel] open-on-send error:', err)); } } @@ -18571,19 +18562,18 @@ export class AgentFramework { destination = this.turnRouting.get(agentName)?.locus ?? null; } } - // Speaking never opens a channel, so in a closed one that isn't a DM the - // announcement says the agent will still see messages addressed to it, - // and the result carries the closed-channel note, as a send's answer does. - // Both are decided from the channel as the announcement goes out: neither - // depends on the post landing, which stays fire-and-forget. Those words - // are true in an open channel too, so they're the ones used when the - // channel's state can't be read. - let closedChannelNote: string | undefined; + // The announcement opens a closed channel as any speech does, unless the + // agent closed or declined it itself (openForSpeech): there it stays + // closed, so the announcement says the agent will still see messages + // addressed to it. That's decided from the channel as the announcement + // goes out, since it doesn't depend on the post landing, which stays + // fire-and-forget. Those words are true in an open channel too, so + // they're the ones used when the channel's state can't be read. let staysClosed = false; if (destination && this.channelRegistry?.getDescriptor(destination)) { - [closedChannelNote] = this.closedChannelNotes(agentName, [destination]); try { - staysClosed = this.closedChannelNoteFor(destination, null) !== undefined; + staysClosed = + this.closedChannelNoteFor(destination, this.channelRegistry.getChannelServerId(destination) ?? null) !== undefined; } catch (err) { staysClosed = true; console.error(`[sleep] ${agentName}: whether ${destination} stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); @@ -18606,9 +18596,6 @@ export class AgentFramework { until, untilLocal: formatZonedDateTime(until, this.timeZone), timeZone: this.timeZone, - ...(closedChannelNote - ? { closedChannelNote: `[closed channel] ${closedChannelNote} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` } - : {}), }, endTurn: true, }); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 258985dbf..609e7a638 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -110,9 +110,6 @@ interface ChannelLifecycleEvent { epochId?: string; wakeCount?: number; source?: string; - /** kind 'desired-state': the agent answered the channel while it was - * closed (noteAnswered). The next desired-state record drops it. */ - answered?: true; messageId?: string; acknowledgment?: string; } @@ -507,18 +504,19 @@ interface ChannelRegistryOptions { textLen: number; }) => void; /** - * Called when the subscription policy opened channels WITHOUT the agent - * asking (admitting newly discovered channels). The host wires this to drop - * a durable notice into the agent's window — the agent must always learn - * that new traffic will start flowing, and that `channel_close` opts out - * (their decision outranks policy). Fires ONCE per channel ever: the - * desired-state decision is durable, so reboots do not re-announce. Must - * not trigger inference. Speaking never opens a channel, so no send or - * delivery is reported here. + * Called when channels were opened WITHOUT the agent asking (subscription + * policy admitting a newly discovered channel, or delivery into a closed + * locus). The host wires this to drop a durable notice into the agent's + * window — the agent must always learn that new traffic will start + * flowing, and that `channel_close` opts out (their decision outranks + * policy). Fires ONCE per channel ever: the desired-state decision is + * durable, so reboots do not re-announce. Must not trigger inference. */ onChannelAutoOpened?: (info: { + /** Agent whose action caused the open (delivery); absent for policy opens. */ + conversationId?: string; serverId: string; - source: 'subscription-policy'; + source: 'subscription-policy' | 'opened-by-delivery'; channels: Array<{ channelId: string; label?: string }>; }) => void; /** @@ -564,19 +562,16 @@ export type ChannelToolOrigin = | { kind: 'module'; moduleName?: string } | { kind: 'agent'; agentName: string }; -/** Push tags that mark something other than a new message: a reaction, its - * removal, an edit or a deletion. discord-mcpl sends these as pushes from an - * open channel too, so on their own they never moved the most recent inbound - * channel (notePushDelivered). */ -const NOT_A_NEW_MESSAGE: ReadonlySet = new Set([ - 'chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted', -]); - /** The machine decision sources a module-origin channel_close may record. * Closed on purpose: honest provenance means a small, auditable vocabulary, * not arbitrary self-description. */ export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']); +/** The agent's own decisions to keep a channel closed: its channel_close and + * its decline of an invitation. Speech opens a closed channel only where + * nobody decided, or a machine did (openForSpeech); it never undoes these. */ +export const AGENT_CLOSE_SOURCES: ReadonlySet = new Set(['agent-tool', 'invitation-declined']); + export class ChannelRegistry { private serverRegistry: McplServerRegistry; private featureSetManager: FeatureSetManager; @@ -596,8 +591,9 @@ export class ChannelRegistry { textLen: number; }) => void; private onChannelAutoOpened?: (info: { + conversationId?: string; serverId: string; - source: 'subscription-policy'; + source: 'subscription-policy' | 'opened-by-delivery'; channels: Array<{ channelId: string; label?: string }>; }) => void; private homeChannelResolver?: (agentName: string) => string | undefined; @@ -686,16 +682,7 @@ export class ChannelRegistry { /** Durable wake count for the active tune-out epoch (replayed from * 'tune-out-wake' lifecycle records; see recordTuneOutWake). */ wakeCount?: number; - /** The agent answered the channel while it was closed (noteAnswered), - * and nothing has decided about it since. */ - answered?: true; }>(); - /** Decisions about channels' desired state so far, and each channel's - * latest, so an answer still in flight when a decision about its channel - * is made marks nothing (noteAnswered's `decidedSince`). Every call that - * decides counts, one that keeps the record as it was included. */ - private decisionCount = 0; - private lastDecisionAt = new Map(); /** One-time migration inputs from the retired recipe auto-open policy. */ private legacyPolicies = new Map(); @@ -1369,12 +1356,6 @@ export class ChannelRegistry { return this.findChannelEntry(channelId)?.open === true; } - /** Whether a channel is a DM by what the registry knows of it: its - * metadata, or the id and label shapes DMs take (isDmChannelId). */ - isDirectMessageChannel(channelId: string): boolean { - return this.isDmChannelId(channelId); - } - getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state; } @@ -1604,7 +1585,6 @@ export class ChannelRegistry { source: typeof event.source === "string" ? event.source : "unknown", tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined, wakeCount: 0, - ...(event.answered === true ? { answered: true as const } : {}), }, ); } else if ( @@ -1972,19 +1952,22 @@ export class ChannelRegistry { source: string, ): void { const key = this.lifecycleKey(serverId, channelId); - this.lastDecisionAt.set(key, ++this.decisionCount); const current = this.desiredStates.get(key); - let recorded = source; + // A machine's close isn't a decision: of a channel with no record (one + // registered lazily, or on a server whose grant lacks channels.lifecycle), + // it records the default that channel already had, so policy and + // bootstrap can still lift it (#417). + let recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source; if (current?.state === desired) { // The same state keeps the decision on record, except that the agent's - // own close or decline replaces a pure default, so it sticks: policy - // and bootstrap lift only 'default-closed'. A machine source never - // replaces one, so what nobody decided stays liftable. Either way the - // write ends an answer (noteAnswered), as a close ended main's open. - recorded = current.source === 'default-closed' && !MACHINE_CLOSE_SOURCES.has(source) - ? source - : current.source; - if (recorded === current.source && !current.answered) return; + // own close or decline replaces a pure default or a machine's close, so + // it sticks: policy and bootstrap lift only 'default-closed', and speech + // opens only what the agent didn't close itself (openForSpeech). A + // machine source never replaces one, so what nobody decided stays + // liftable. + const replaceable = current.source === 'default-closed' || MACHINE_CLOSE_SOURCES.has(current.source); + recorded = replaceable && AGENT_CLOSE_SOURCES.has(source) ? source : current.source; + if (recorded === current.source) return; } this.desiredStates.set(key, { state: desired, source: recorded }); this.appendLifecycleEvent({ @@ -1997,77 +1980,6 @@ export class ChannelRegistry { }); } - /** How many decisions about channels' desired state the registry has - * taken: read before a publish or a send, and passed to noteAnswered after. */ - decisionSequence(): number { - return this.decisionCount; - } - - /** - * The agent's speech, or a send tool's call, delivered into this channel - * while it was closed. Speaking never opens a channel, but where the - * agent's next turn with no trigger speaks follows the channels it answered, - * as it did when answering opened them: a push from an answered channel - * moves the most recent inbound channel (notePushDelivered), as an open - * channel's channels/incoming does. Kept on the desired-state record, so it - * survives a restart, until the next decision about the channel: an open, - * a close, a decline or a tune-out. A decision made while the answer was - * in flight, after `decidedSince` (decisionSequence() taken before it was - * sent), stands too: the answer marks nothing, as when the agent closes a - * channel in the same round it answers there. It runs after a delivered - * publish, so a store that fails to record it is logged, not thrown: the - * publish stands, and the mark holds until this process ends. - */ - noteAnswered(channelId: string, serverId: string, decidedSince: number): void { - const entry = this.channels.get(`${serverId}:${channelId}`); - if (!entry || entry.open) return; - const key = this.lifecycleKey(serverId, channelId); - if ((this.lastDecisionAt.get(key) ?? 0) > decidedSince) return; - const current = this.desiredStates.get(key); - if (current?.answered || current?.state === 'tuned-out') return; - const state = current?.state ?? 'closed'; - const source = current?.source ?? 'default-closed'; - this.desiredStates.set(key, { ...current, state, source, answered: true }); - try { - this.appendLifecycleEvent({ - kind: 'desired-state', - serverId, - channelId, - desired: state, - source, - answered: true, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error(`[channel] answered mark for ${logValue(channelId)} not recorded: ${logDetail((error as Error).message)}`); - } - } - - /** - * A push was accepted from this channel. One from a channel the agent - * answered while it was closed (noteAnswered), and that is closed still, is - * where the last message came in, as an accepted channels/incoming message - * is (markAccepted), so a turn with no trigger speaks there. Once the - * channel's transport is open, its messages arrive as channels/incoming and - * move it there. A push from any other channel moves nothing, as before, - * and neither does a reaction, an edit or a deletion: those arrive as pushes - * from an open channel too, and never moved it. - */ - notePushDelivered( - serverId: string, - channelId: string, - messageId?: string, - threadId?: string, - tags?: readonly string[], - ): void { - if (!this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.answered) return; - if (this.channels.get(`${serverId}:${channelId}`)?.open) return; - if (tags?.some((t) => NOT_A_NEW_MESSAGE.has(t))) return; - this.defaultPublishChannel = channelId; - this.defaultPublishMessageId = messageId ?? null; - this.defaultPublishThreadId = threadId; - } - // ========================================================================== // Tune-out state (issue #77) — durable in the lifecycle log // ========================================================================== @@ -2085,7 +1997,6 @@ export class ChannelRegistry { source: string, ): void { const key = this.lifecycleKey(serverId, channelId); - this.lastDecisionAt.set(key, ++this.decisionCount); this.desiredStates.set(key, { state: 'tuned-out', source, @@ -2119,7 +2030,6 @@ export class ChannelRegistry { const current = this.desiredStates.get(key); if (current?.state !== 'tuned-out' || !current.tuneOut) return null; const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 }; - this.lastDecisionAt.set(key, ++this.decisionCount); this.desiredStates.set(key, { state: nextState, source }); this.appendLifecycleEvent({ kind: 'desired-state', @@ -2500,13 +2410,14 @@ export class ChannelRegistry { } /** - * The open executor behind the agent's channel_open tool: record durable - * desired-open intent, tell the server to subscribe, mark the live entry - * open. Speaking never opens a channel, so no send or delivery comes here: - * an agent's speech into a closed channel publishes, and the channel's - * open state and recorded decision stay as they were. Desired state is - * recorded BEFORE the server round-trip: intent sticks even if the - * subscribe fails, and reconciliation retries later. + * The single open executor: record durable desired-open intent, tell the + * server to subscribe, mark the live entry open. Every path that opens a + * channel — the agent's channel_open tool, and speech or an explicit send + * into a closed channel nobody decided to keep closed (openForSpeech) — + * funnels here, so lifecycle events, desired state, and live state can + * never diverge by path. + * Desired state is recorded BEFORE the server round-trip: intent sticks + * even if the subscribe fails, and reconciliation retries later. */ private async openChannelNow( entry: ChannelEntry, @@ -2719,11 +2630,8 @@ export class ChannelRegistry { * MCPL id, or the raw server-internal id (`address.channelId`), since send * tools receive whatever the agent typed. Matches on `serverId` when it is * given, across every server otherwise, and is undefined when nothing - * matches or more than one channel does. A lookup only: speaking never - * opens a channel, so a send into a closed channel leaves it closed, and - * its recorded decision as it was. Typing indicators don't depend on a - * channel being open; opening subscribes to its ongoing traffic (on - * discord-mcpl, its reactions too). + * matches or more than one channel does. A lookup only; openForSpeech + * decides whether the send opens it. */ resolveSendTarget(channelId: string, serverId?: string): string | undefined { let matches = [...this.channels.values()].filter( @@ -2739,6 +2647,64 @@ export class ChannelRegistry { return matches.length === 1 ? matches[0]!.descriptor.id : undefined; } + /** + * How the agent kept this channel closed itself, if it did: 'closed' for + * its channel_close, 'declined' for its channel_decline. Its speech there + * leaves such a channel closed (openForSpeech). + */ + closedByAgent(serverId: string, channelId: string): 'closed' | 'declined' | undefined { + const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); + if (current?.state !== 'closed') return undefined; + if (current.source === 'agent-tool') return 'closed'; + if (current.source === 'invitation-declined') return 'declined'; + return undefined; + } + + /** Whether this server's grant lets channels open and close at all + * (channels.lifecycle, §14.1): without it, channel_open fails. */ + canOpenChannels(serverId: string): boolean { + const server = this.serverRegistry.getServer(serverId); + return !!server && CapabilityGrant.of(server).has('channels.lifecycle'); + } + + /** + * Open a channel because the agent's words were delivered into it (routed + * speech, or an explicit send tool), so typing indicators, reaction + * machinery and inbound forwarding come alive with the reply. Only a + * channel nobody decided to keep closed opens: one with no decision on + * record, a pure default, or a machine's close (subscription-gc, + * housekeeping). The agent's own close or decline stands, and so does a + * tune-out; a channel decided open whose transport is down is + * reconciliation's to reopen, so its decision keeps its source. A failed + * open is traced and reported, and never stops the words: they're + * delivered either way. + */ + async openForSpeech( + serverId: string, + channelId: string, + source: 'opened-by-delivery' | 'opened-by-reply', + ): Promise<'opened' | 'not-opened' | 'open-failed' | 'unknown-channel'> { + const entry = this.channels.get(`${serverId}:${channelId}`); + if (!entry) return 'unknown-channel'; + if (entry.open) return 'not-opened'; + const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); + if (current?.state === 'open' || current?.state === 'tuned-out') return 'not-opened'; + if (current?.state === 'closed' && AGENT_CLOSE_SOURCES.has(current.source)) return 'not-opened'; + try { + await this.openChannelNow(entry, source); + this.emitTraceFn({ type: 'mcpl:channel-opened-by-send', serverId, channelId }); + return 'opened'; + } catch (err) { + this.emitTraceFn({ + type: 'mcpl:channel-open-failed', + serverId, + channelId, + error: (err as Error).message, + }); + return 'open-failed'; + } + } + private async handleToolOpen(input: { channelId: string; serverId?: string; @@ -2856,9 +2822,10 @@ export class ChannelRegistry { const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed'; if (!entry.open && alreadyDesiredClosed) { - // Nothing to send, but a close of a channel nobody decided about is - // still a decision: recording it keeps policy and bootstrap, which lift - // only a pure default, from opening the channel later. + // Nothing to send, but the agent's close of a channel nobody decided + // about, or a machine closed, is still its decision: recording it keeps + // policy and bootstrap, which lift only a pure default, and the agent's + // own speech there (openForSpeech) from opening the channel later. this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); return { success: true, @@ -3195,12 +3162,36 @@ export class ChannelRegistry { return fail(channelId, `no registered channel for locus "${channelId}"`); } - // Speaking never opens a channel: speech into a closed but registered - // locus (such as a DM that woke the agent) publishes, and the channel's - // open state and recorded decision stay as they were. Publishing doesn't - // need an open channel, and neither does typing; opening subscribes to - // the channel's ongoing traffic (on discord-mcpl, its reactions too), - // which is channel_open's. + // Speech routed into a closed but registered locus (the canonical case: + // a DM that woke the agent; DMs register closed) opens the channel + // first, so typing indicators, reaction machinery and inbound forwarding + // come alive with the reply, unless the agent closed or declined it + // itself (openForSpeech). A failed open doesn't stop the words: a server + // whose grant lacks channels.lifecycle can't open a channel at all, and + // a dead transport fails the publish below, which the agent is told. + if (!entry.open) { + const opened = await this.openForSpeech(entry.serverId, channelId, 'opened-by-delivery'); + if (opened === 'opened') { + console.error( + `[routeSpeech] ${conversationId}: locus ${logValue(channelId)} was closed — opened by delivery (speech implies engagement)`, + ); + try { + this.onChannelAutoOpened?.({ + conversationId, + serverId: entry.serverId, + source: 'opened-by-delivery', + channels: [{ channelId, label: entry.descriptor.label }], + }); + } catch (err) { + console.error('onChannelAutoOpened (delivery) failed:', err); + } + } else if (opened === 'open-failed') { + console.error( + `[routeSpeech] ${conversationId}: locus ${logValue(channelId)} is closed and its open failed; publishing anyway`, + ); + } + } + const server = this.serverRegistry.getServer(entry.serverId); if (!server) { return fail(channelId, `server "${entry.serverId}" not found`); @@ -3217,8 +3208,14 @@ export class ChannelRegistry { channelId, content: [{ type: 'text', text }], }; - const decidedSince = this.decisionCount; - const result = await server.sendChannelsPublish(publishParams); + let result: unknown; + try { + result = await server.sendChannelsPublish(publishParams); + } catch (err) { + // A thrown publish is a failed route, which the agent is told, as a + // refused one is. + return fail(channelId, `publish failed: ${(err as Error).message}`); + } const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; // Surface the posted message's id (ChannelsPublishResult.messageId) so // trace consumers can act on the just-posted message — e.g. a TTS-relay @@ -3232,9 +3229,6 @@ export class ChannelRegistry { if (delivered === false) { return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`); } - // Answering a closed channel leaves it closed, and where a turn with no - // trigger speaks still follows the agent there (noteAnswered). - if (!entry.open) this.noteAnswered(channelId, entry.serverId, decidedSince); console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`); this.emitTraceFn({ diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index a134a41d2..7615256b1 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -205,10 +205,6 @@ export interface CoalescerHost { /** Persist one receipt now, before the acceptance is acknowledged. Throws * on failure, which fails the acceptance (the producer retries). */ recordReceipt?(record: CoalescingReceiptRecord): void; - /** One call per ADMITTED occurrence (never for a producer retry the receipt - * deduplicates), after its receipt is recorded. Observation only: a throw - * is contained here and never fails the acceptance. */ - accepted?(occurrence: CoalescedOccurrence): void; /** Make every write so far DURABLE (fsync, group-committed by the host). * Awaited before an acceptance is acknowledged and before push/render is * issued: a kill after either boundary must find the receipt, the @@ -489,11 +485,6 @@ export class PushCoalescer { throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000); } this.receipts.set(receiptKey, entry); - try { - this.host.accepted?.(occurrence); - } catch (error) { - this.host.audit({ kind: 'accepted-observer-failed', subject, eventId: occurrence.eventId, error: String(error) }); - } this.prune(); this.persist(); // Written but not yet durable: the in-memory receipt deduplicates a retry diff --git a/test/answered-channel-fallback.test.ts b/test/answered-channel-fallback.test.ts deleted file mode 100644 index 5c6160e34..000000000 --- a/test/answered-channel-fallback.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Speaking never opens a channel, so a channel the agent answers stays - * closed, and its later messages keep arriving as push events. Where a turn - * with no trigger (a heartbeat) speaks still follows the agent there: on main - * the answer opened the channel, and its channels/incoming messages moved the - * most recent inbound channel. A push from a channel the agent answered while - * it was closed now moves it the same way, until the next decision about the - * channel; a push from any other channel moves nothing, as before. The test - * runs over a real MCPL wire (a loopback server) into the framework's own - * ChannelRegistry. - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; - -import { fixture, TS } from './helpers/coalescing-fixture.js'; -import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; - -type Fixture = Awaited>; - -const registryOf = (f: Fixture) => (f.framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; - -/** A message from `channelId` that addresses the agent, as a closed channel's - * connector sends it; `coalesce` makes it a channel-scoped coalesced push. */ -const push = (eventId: string, channelId: string, tags: string[], coalesce?: Record) => ({ - featureSet: 'doc', - eventId, - timestamp: TS, - origin: { mcplChannelId: channelId, messageId: `m-${eventId}`, authorId: 'u', authorName: 'User' }, - tags, - payload: { content: [{ type: 'text', text: `message ${eventId}` }] }, - ...(coalesce ? { coalesce: { channelId, key: `message:m-${eventId}`, ...coalesce } } : {}), -}); - -async function setUp(f: Fixture) { - await f.send('channels/register', { - channels: ['lobby', 'dm', 'quiet'].map((id) => ({ id, type: 'discord', label: id })), - }); - // An open channel's message: the most recent inbound channel is the lobby. - await f.send('channels/incoming', { - messages: [{ channelId: 'lobby', messageId: 'l1', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'hello all' }] }], - }); - const registry = registryOf(f); - assert.equal(registry.getDefaultPublishChannel(), 'lobby'); - assert.equal(registry.isChannelOpen('dm'), false); - return registry; -} - -test('a push from a channel the agent answered while closed is where a turn with no trigger speaks', async (t) => { - const f = await fixture(); t.after(f.close); - const registry = await setUp(f); - - // Petra's control: a closed channel nobody answered moves nothing. - await f.send('push/event', push('q1', 'quiet', ['chat:mention', 'chat:addressed'])); - assert.equal(registry.getDefaultPublishChannel(), 'lobby'); - - // The agent answers the DM; it stays closed. - assert.deepEqual(await registry.routeSpeech('agent', 'happy to help', 'dm'), { delivered: true, channelId: 'dm' }); - assert.equal(registry.isChannelOpen('dm'), false); - - // A reaction to the answer isn't where a message came in. - await f.send('push/event', push('r1', 'dm', ['chat:reaction'])); - assert.equal(registry.getDefaultPublishChannel(), 'lobby'); - - // The DM's next message arrives by push, and a turn with no trigger speaks there. - await f.send('push/event', push('d1', 'dm', ['chat:dm', 'chat:addressed', 'chat:private'])); - assert.equal(registry.getDefaultPublishChannel(), 'dm'); - assert.equal(registry.resolveLocus('agent'), 'dm'); - await registry.routeSpeech('agent', 'the heartbeat speaks', registry.resolveLocus('agent')); - assert.equal(f.published.at(-1)?.channelId, 'dm'); - - // The agent's close is the next decision about it: the answer ends there. - await f.send('channels/incoming', { - messages: [{ channelId: 'lobby', messageId: 'l2', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'again' }] }], - }); - await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, { kind: 'agent', agentName: 'agent' }); - await f.send('push/event', push('d2', 'dm', ['chat:dm', 'chat:addressed', 'chat:private'])); - assert.equal(registry.getDefaultPublishChannel(), 'lobby'); -}); - -test('the answer survives a restart: after a reload, a push from the answered channel still moves the fallback', async (t) => { - const f = await fixture(); t.after(f.close); - const registry = await setUp(f); - await registry.routeSpeech('agent', 'happy to help', 'dm'); - - await f.framework.stop(); - await f.create(); - const reloaded = await setUp(f); - assert.notEqual(reloaded, registry, 'a new framework, over the same store'); - - await f.send('push/event', push('q1', 'quiet', ['chat:mention', 'chat:addressed'])); - assert.equal(reloaded.getDefaultPublishChannel(), 'lobby'); - await f.send('push/event', push('d1', 'dm', ['chat:dm', 'chat:addressed'])); - assert.equal(reloaded.getDefaultPublishChannel(), 'dm'); -}); - -test("a coalesced push counts once, when it's admitted afresh, and a withdrawal never does", async (t) => { - const f = await fixture(); t.after(f.close); - const registry = await setUp(f); - await registry.routeSpeech('agent', 'happy to help', 'dm'); - - const first = push('c1', 'dm', ['chat:dm', 'chat:addressed'], { initial: true }); - await f.send('push/event', first); - assert.equal(registry.getDefaultPublishChannel(), 'dm', 'the channel-scoped push a connector sends for a closed channel'); - - // The lobby speaks; then the producer retries the DM push it already sent. - await f.send('channels/incoming', { - messages: [{ channelId: 'lobby', messageId: 'l2', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'again' }] }], - }); - await f.send('push/event', first); - assert.equal(registry.getDefaultPublishChannel(), 'lobby', "a retry of acknowledged work isn't a new message"); - - // A push scoped to its feature set, whose channel comes from its origin, as - // discord-mcpl sends one for a channel it hasn't declared. - await f.send('push/event', { ...push('c3', 'dm', ['chat:dm', 'chat:addressed']), coalesce: { key: 'message:m-c3', initial: true } }); - assert.equal(registry.getDefaultPublishChannel(), 'dm', 'a feature-set-scoped push counts by its origin'); - await f.send('channels/incoming', { - messages: [{ channelId: 'lobby', messageId: 'l3', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'and again' }] }], - }); - - // A withdrawal of the DM's message, tagged as a message so only the - // withdrawal itself can keep it from counting. - const withdrawal = { ...push('c2', 'dm', ['chat:dm', 'chat:addressed']), coalesce: { channelId: 'dm', key: 'message:m-c1', retract: true }, payload: { content: [] } }; - const r = await f.send('push/event', withdrawal); - assert.equal(r.error, undefined, 'the withdrawal is accepted'); - assert.equal(registry.getDefaultPublishChannel(), 'lobby', 'a withdrawal is never where a message came in'); -}); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index d40116c1e..0970a86e1 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -17,8 +17,9 @@ function makeRegistry( homeChannelResolver?: (agentName: string) => string | undefined, activeChannelResolver?: (agentName: string) => string | undefined, onChannelAutoOpened?: (info: { + conversationId?: string; serverId: string; - source: 'subscription-policy'; + source: 'subscription-policy' | 'opened-by-delivery'; channels: Array<{ channelId: string; label?: string }>; }) => void, ) { @@ -28,15 +29,19 @@ function makeRegistry( const openCalls: Array<{ channelId?: string }> = []; const closeCalls: Array<{ channelId?: string }> = []; + let failOpens = false; + let failPublishes = false; const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), sendChannelsPublish: async (params: { channelId?: string; conversationId?: string }) => { publishCalls.push(params); + if (failPublishes) throw new Error('transport closed'); return publishResult; }, sendChannelsOpen: async (params: { channelId?: string }) => { + if (failOpens) throw new Error('open refused by server'); openCalls.push(params); return {}; }, @@ -69,7 +74,11 @@ function makeRegistry( findChannelEntry(id: string): { serverId: string; open: boolean; descriptor: { id: string; label: string; metadata?: Record } } | undefined; }).findChannelEntry(channelId); - return { registry, failures, traces, publishCalls, openCalls, closeCalls, lookup }; + return { + registry, failures, traces, publishCalls, openCalls, closeCalls, lookup, + setFailOpens: (v: boolean) => { failOpens = v; }, + setFailPublishes: (v: boolean) => { failPublishes = v; }, + }; } function incoming(channelId: string, text: string, channelName?: string) { @@ -310,38 +319,47 @@ test('ensureChannelRegistered is idempotent and does not reopen a closed channel }); // --------------------------------------------------------------------------- -// Channel-lifecycle invariants: speaking never opens a channel; subscribed ⇒ -// open at any discovery time. +// Channel-lifecycle invariants (2026-07-22): sending into a closed channel is +// not a thing — delivery opens it; subscribed ⇒ open at any discovery time. // --------------------------------------------------------------------------- -test('routeSpeech into a closed channel publishes and leaves it closed (speaking never opens)', async () => { - const autoOpened: unknown[] = []; - const { registry, publishCalls, openCalls, lookup } = makeRegistry( - { delivered: true }, undefined, undefined, (info) => autoOpened.push(info), - ); - const setDesired = (registry as unknown as { - setDesiredState(s: string, c: string, d: 'open' | 'closed', src: string): void; - }).setDesiredState.bind(registry); - - // A channel the agent closed, and one it opened whose transport is down (a - // failed open): both registered with the transport flag false. - seedRegistered(registry, 'discord', 'upkeep', 'standup'); - setDesired('discord', 'upkeep', 'closed', 'agent-tool'); - setDesired('discord', 'standup', 'open', 'agent-tool'); - - for (const channelId of ['upkeep', 'standup']) { - const res = await registry.routeSpeech('sol', `a reply into ${channelId}`, channelId); - assert.deepEqual(res, { delivered: true, channelId }); - assert.equal(publishCalls.at(-1)?.channelId, channelId, 'the speech is published'); - assert.equal(lookup(channelId)!.open, false, 'the transport flag is left as it was'); - } - assert.equal(openCalls.length, 0, 'speaking never opens a channel'); - assert.equal(registry.getDesiredState('discord', 'upkeep'), 'closed', "the agent's close stands"); - assert.equal(registry.getDesiredState('discord', 'standup'), 'open'); - assert.equal(autoOpened.length, 0, 'nothing is announced as opened'); +test('routeSpeech into a closed locus opens the channel first, then delivers', async () => { + const { registry, publishCalls, openCalls, lookup } = makeRegistry({ delivered: true }); + + // A DM-shaped situation: the channel is registered but closed. + seedRegistered(registry, 'discord', 'dm-alice'); + registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + lookup('dm-alice')!.open = false; + + const res = await registry.routeSpeech('sol', 'a reply meant for the DM', 'dm-alice'); + + assert.deepEqual(res, { delivered: true, channelId: 'dm-alice' }); + assert.equal(openCalls.length, 1, 'delivery into a closed channel must open it'); + assert.equal(openCalls[0]!.channelId, 'dm-alice'); + assert.equal(lookup('dm-alice')!.open, true, 'live state flips open'); + assert.equal(registry.getDesiredState('discord', 'dm-alice'), 'open', + 'durable desired state records the engagement'); + assert.equal(publishCalls.at(-1)?.channelId, 'dm-alice'); +}); + +test('routeSpeech still delivers when the open on delivery fails', async () => { + const { registry, failures, publishCalls, traces, lookup, setFailOpens } = makeRegistry({ delivered: true }); + + seedRegistered(registry, 'discord', 'dm-alice'); + registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + lookup('dm-alice')!.open = false; + setFailOpens(true); + + const res = await registry.routeSpeech('sol', 'goes out anyway', 'dm-alice'); + + assert.deepEqual(res, { delivered: true, channelId: 'dm-alice' }, 'the words are delivered'); + assert.equal(publishCalls.at(-1)?.channelId, 'dm-alice'); + assert.deepEqual(failures, [], 'a failed open is no failed route'); + assert.ok(traces.some((t) => t.type === 'mcpl:channel-open-failed' && t.channelId === 'dm-alice'), 'the failed open is traced'); + assert.equal(lookup('dm-alice')!.open, false); }); -test('routeSpeech into a closed channel publishes on a server whose grant lacks channels.lifecycle', async () => { +test('routeSpeech publishes on a server whose grant lacks channels.lifecycle, where nothing can open', async () => { const published: Array = []; const server = { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), []), @@ -360,148 +378,80 @@ test('routeSpeech into a closed channel publishes on a server whose grant lacks const res = await registry.routeSpeech('sol', 'hello from outside', 'lobby'); - assert.deepEqual(res, { delivered: true, channelId: 'lobby' }, 'no open stands in the way'); + assert.deepEqual(res, { delivered: true, channelId: 'lobby' }, 'the open fails, and the words still go'); assert.deepEqual(published, ['lobby']); + assert.equal(registry.canOpenChannels('portal'), false); }); -test("the agent's close or decline of a channel nobody decided about sticks against later policy; a machine close doesn't", async () => { - const { registry, openCalls } = makeRegistry({ delivered: true }); - const added = { +test('routeSpeech reports a publish that throws as a failed route (R1-4)', async () => { + const { registry, failures, setFailPublishes } = makeRegistry({ delivered: true }); + seedRegistered(registry, 'discord', 'ch-z'); + registry.handleIncoming('discord', incoming('ch-z', 'hi')); + setFailPublishes(true); + + const res = await registry.routeSpeech('sol', 'into a dead transport', 'ch-z'); + + assert.equal(res, null); + assert.equal(failures.length, 1); + assert.equal(failures[0]!.channelId, 'ch-z'); + assert.match(failures[0]!.reason, /^publish failed: transport closed$/); +}); + +test("routeSpeech leaves a channel the agent closed or declined itself closed, and publishes there", async () => { + const { registry, publishCalls, openCalls, lookup } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { added: [ { id: 'closed-by-agent', type: 'discord', label: '#a', direction: 'bidirectional' }, { id: 'declined-by-agent', type: 'discord', label: '#b', direction: 'bidirectional' }, - { id: 'closed-by-gc', type: 'discord', label: '#c', direction: 'bidirectional' }, ], - } as never; - // Registered closed by default: nobody has decided about any of them. - await registry.handleChanged('discord', added); + } as never); const origin = { kind: 'agent' as const, agentName: 'sol' }; - const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'closed-by-agent' }, origin); - assert.deepEqual(closed.data, { channelId: 'closed-by-agent', status: 'already closed' }); - const declined = await registry.handleChannelToolCall( - 'channel_decline', { channelId: 'declined-by-agent', messageId: 'm1' }, origin); - assert.equal(declined.success, true); - // connectome-host's subscription-gc closes an idle channel in this shape. - const gc = await registry.handleChannelToolCall( - 'channel_close', { channelId: 'closed-by-gc', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); - assert.deepEqual(gc.data, { channelId: 'closed-by-gc', status: 'already closed' }); - - // Policy now admits every channel, and all three register again. - registry.setSubscriptionPolicy('discord', 'auto'); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'closed-by-agent' }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'closed-by-agent' }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall( + 'channel_decline', { channelId: 'declined-by-agent', messageId: 'm1' }, origin)).success, true); const opensBefore = openCalls.length; - await registry.handleChanged('discord', added); - assert.equal(registry.getDesiredState('discord', 'closed-by-agent'), 'closed', 'the close sticks'); - assert.equal(registry.getDesiredState('discord', 'declined-by-agent'), 'closed', 'the decline sticks'); - assert.equal(registry.getDesiredState('discord', 'closed-by-gc'), 'open', 'a machine close decided nothing'); - assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['closed-by-gc'], 'policy opens only that one'); -}); - -test('only a delivered answer into a closed channel marks it, and only its conversational pushes count while it stays closed', async () => { - const conversational = ['chat:dm', 'chat:addressed']; - // A refused publish answers nothing. - const refused = makeRegistry({ delivered: false }); - seedRegistered(refused.registry, 'discord', 'dm'); - assert.equal(await refused.registry.routeSpeech('sol', 'not delivered', 'dm'), null); - refused.registry.notePushDelivered('discord', 'dm', 'm1', undefined, conversational); - assert.equal(refused.registry.getDefaultPublishChannel(), null, 'a refused publish answers nothing'); - - const { registry } = makeRegistry({ delivered: true }); - seedRegistered(registry, 'discord', 'dm', 'lobby'); - await registry.routeSpeech('sol', 'answered', 'dm'); - registry.handleIncoming('discord', incoming('lobby', 'hi')); - for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { - registry.notePushDelivered('discord', 'dm', 'm2', undefined, [tag]); - assert.equal(registry.getDefaultPublishChannel(), 'lobby', `${tag} isn't a message coming in`); + for (const id of ['closed-by-agent', 'declined-by-agent']) { + const res = await registry.routeSpeech('sol', 'answering', id); + assert.deepEqual(res, { delivered: true, channelId: id }); + assert.equal(publishCalls.at(-1)?.channelId, id); + assert.equal(lookup(id)!.open, false, `${id} stays closed`); + assert.equal(registry.getDesiredState('discord', id), 'closed'); } - registry.notePushDelivered('discord', 'dm', 'm3', undefined, conversational); - assert.equal(registry.getDefaultPublishChannel(), 'dm'); - - // Its transport opens (the server delivers channels/incoming from it): its - // messages move the fallback that way now, and a push doesn't. - registry.handleIncoming('discord', incoming('dm', 'over incoming')); - registry.handleIncoming('discord', incoming('lobby', 'hi again')); - registry.notePushDelivered('discord', 'dm', 'm4', undefined, conversational); - assert.equal(registry.getDefaultPublishChannel(), 'lobby'); + assert.equal(openCalls.length, opensBefore, 'speech opened neither'); + assert.equal(registry.closedByAgent('discord', 'closed-by-agent'), 'closed'); + assert.equal(registry.closedByAgent('discord', 'declined-by-agent'), 'declined'); }); -test('the next decision about an answered channel ends its answer', async () => { - const origin = { kind: 'agent' as const, agentName: 'sol' }; - const dm = { added: [{ id: 'dm', type: 'discord', label: 'DM: antra', direction: 'bidirectional' }] } as never; - const cases: Array<[string, (r: ChannelRegistry, lookup: (id: string) => { open: boolean } | undefined) => Promise]> = [ - ['channel_open', async (r, lookup) => { - await r.handleChannelToolCall('channel_open', { channelId: 'dm' }, origin); - lookup('dm')!.open = false; // and its transport drops again later - }], - ['channel_decline', (r) => r.handleChannelToolCall('channel_decline', { channelId: 'dm', messageId: 'm0' }, origin)], - ['a tune-out', async (r) => r.enterTuneOut('discord', 'dm', { - epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, - }, 'agent-tool')], - ['a machine close', (r) => r.handleChannelToolCall( - 'channel_close', { channelId: 'dm', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' })], - // The agent had closed it before it answered, so this close keeps the - // same decision on record and still ends the answer. - ["the agent's close, again", (r) => r.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin)], - ]; - for (const [name, decide] of cases) { - const { registry, lookup } = makeRegistry({ delivered: true }); - await registry.handleChanged('discord', dm); - seedRegistered(registry, 'discord', 'lobby'); - if (name === "the agent's close, again") { - await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin); - } - await registry.routeSpeech('sol', 'answered', 'dm'); - await decide(registry, lookup); - registry.handleIncoming('discord', incoming('lobby', 'elsewhere')); - registry.notePushDelivered('discord', 'dm', 'm1', undefined, ['chat:dm', 'chat:addressed']); - assert.equal(registry.getDefaultPublishChannel(), 'lobby', `${name} ends the answer`); - if (name === 'a machine close') { - assert.equal(registry.getDesiredState('discord', 'dm'), 'closed'); - registry.setSubscriptionPolicy('discord', 'auto'); - await registry.handleChanged('discord', dm); - assert.equal(registry.getDesiredState('discord', 'dm'), 'open', 'and decides nothing: policy may still open it'); - } +test("speech opens a channel a machine closed, and the agent's own close of it then stands against its speech", async () => { + const { registry, openCalls, lookup } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { + added: [ + { id: 'gc-a', type: 'discord', label: '#a', direction: 'bidirectional' }, + { id: 'gc-b', type: 'discord', label: '#b', direction: 'bidirectional' }, + ], + } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + const gc = { kind: 'module' as const, moduleName: 'subscription-gc' }; + for (const id of ['gc-a', 'gc-b']) { + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: id }, agent)).success, true); + assert.equal((await registry.handleChannelToolCall( + 'channel_close', { channelId: id, source: 'subscription-gc', overrideExplicitOpen: true }, gc)).success, true); + assert.equal(registry.closedByAgent('discord', id), undefined, 'a machine close is no decision of the agent'); } -}); + // The agent closes one of them itself: its close replaces the machine's. + const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'gc-b' }, agent); + assert.deepEqual(closed.data, { channelId: 'gc-b', status: 'already closed' }); + assert.equal(registry.closedByAgent('discord', 'gc-b'), 'closed'); + const opensBefore = openCalls.length; -test("an answer still in flight when the agent decides about its channel marks nothing", async () => { - const origin = { kind: 'agent' as const, agentName: 'sol' }; - // Once with no decision on record, and once over the agent's own earlier - // close, where closing again keeps the record as it was. - for (const closedBefore of [false, true]) { - let release!: () => void; - const held = new Promise<{ delivered: boolean }>((resolve) => { release = () => resolve({ delivered: true }); }); - const { registry } = makeRegistry(held as unknown as { delivered: boolean }); - seedRegistered(registry, 'discord', 'dm', 'lobby'); - if (closedBefore) await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin); - const speaking = registry.routeSpeech('sol', 'answering', 'dm'); - // The same round closes the channel while the publish is in flight. - assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'dm' }, origin)).success, true); - release(); - assert.ok(await speaking, 'the answer is still delivered'); - registry.handleIncoming('discord', incoming('lobby', 'elsewhere')); - registry.notePushDelivered('discord', 'dm', 'm2', undefined, ['chat:dm', 'chat:addressed']); - assert.equal(registry.getDefaultPublishChannel(), 'lobby', `the close stands (closed before: ${closedBefore})`); - } -}); + await registry.routeSpeech('sol', 'answering', 'gc-a'); + await registry.routeSpeech('sol', 'answering', 'gc-b'); -test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { - const { registry, openCalls } = makeRegistry({ delivered: true }); - const added = (id: string, raw: string) => ({ - id, type: 'discord', label: `#${raw}`, direction: 'bidirectional', address: { guildId: 'g', channelId: raw }, - }); - await registry.handleChanged('discord-a', { added: [added('discord:g:444', '444'), added('discord:g:555', '555')] } as never); - await registry.handleChanged('discord-b', { added: [added('discord:h:555', '555')] } as never); - const opensBefore = openCalls.length; - // Awaited, so the test holds whether the lookup answers at once or later. - - assert.equal(await registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); - assert.equal(await registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); - assert.equal(await registry.resolveSendTarget('444', 'discord-b'), undefined, 'not on another server'); - assert.equal(await registry.resolveSendTarget('555'), undefined, 'a raw id two servers share'); - assert.equal(await registry.resolveSendTarget('555', 'discord-b'), 'discord:h:555', 'the server settles it'); - assert.equal(await registry.resolveSendTarget('999', 'discord-a'), undefined, 'an unknown id'); - assert.equal(openCalls.length, opensBefore, 'a lookup opens nothing'); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['gc-a'], 'speech opens only the machine-closed one'); + assert.equal(lookup('gc-a')!.open, true); + assert.equal(lookup('gc-b')!.open, false); }); test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { @@ -571,8 +521,108 @@ test('an agent decision to stay closed sticks — policy does not override chann assert.ok(!openCalls.some(c => c.channelId === 'declined-chan')); }); +test("the agent's close or decline of a channel nobody decided about sticks against later policy; a machine close doesn't", async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + const added = { + added: [ + { id: 'closed-by-agent', type: 'discord', label: '#a', direction: 'bidirectional' }, + { id: 'declined-by-agent', type: 'discord', label: '#b', direction: 'bidirectional' }, + { id: 'closed-by-gc', type: 'discord', label: '#c', direction: 'bidirectional' }, + ], + } as never; + // Registered closed by default: nobody has decided about any of them. + await registry.handleChanged('discord', added); + const origin = { kind: 'agent' as const, agentName: 'sol' }; + const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'closed-by-agent' }, origin); + assert.deepEqual(closed.data, { channelId: 'closed-by-agent', status: 'already closed' }); + const declined = await registry.handleChannelToolCall( + 'channel_decline', { channelId: 'declined-by-agent', messageId: 'm1' }, origin); + assert.equal(declined.success, true); + // connectome-host's subscription-gc closes an idle channel in this shape. + const gc = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'closed-by-gc', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + assert.deepEqual(gc.data, { channelId: 'closed-by-gc', status: 'already closed' }); + + // Policy now admits every channel, and all three register again. + registry.setSubscriptionPolicy('discord', 'auto'); + const opensBefore = openCalls.length; + await registry.handleChanged('discord', added); + + assert.equal(registry.getDesiredState('discord', 'closed-by-agent'), 'closed', 'the close sticks'); + assert.equal(registry.getDesiredState('discord', 'declined-by-agent'), 'closed', 'the decline sticks'); + assert.equal(registry.getDesiredState('discord', 'closed-by-gc'), 'open', 'a machine close decided nothing'); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['closed-by-gc'], 'policy opens only that one'); +}); + +test("a machine's close of a channel with no record leaves it the default it was, so policy can still open it (#417)", async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + // Registered lazily, from a message: no decision on record. + registry.ensureChannelRegistered('discord', 'lazy-1', '#lazy'); + const gc = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'lazy-1', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + assert.equal(gc.success, true); + assert.equal(registry.getDesiredState('discord', 'lazy-1'), 'closed'); + assert.equal(registry.closedByAgent('discord', 'lazy-1'), undefined); + + // Policy admits everything, and the channel registers again: policy lifts the default. + registry.setSubscriptionPolicy('discord', 'auto'); + const opensBefore = openCalls.length; + await registry.handleChanged('discord', { + added: [{ id: 'lazy-1', type: 'discord', label: '#lazy', direction: 'bidirectional' }], + } as never); + assert.equal(registry.getDesiredState('discord', 'lazy-1'), 'open', 'nobody decided, so policy decides'); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['lazy-1']); +}); + +test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + const added = (id: string, raw: string) => ({ + id, type: 'discord', label: `#${raw}`, direction: 'bidirectional', address: { guildId: 'g', channelId: raw }, + }); + await registry.handleChanged('discord-a', { added: [added('discord:g:444', '444'), added('discord:g:555', '555')] } as never); + await registry.handleChanged('discord-b', { added: [added('discord:h:555', '555')] } as never); + const opensBefore = openCalls.length; + + assert.equal(registry.resolveSendTarget('discord:g:444'), 'discord:g:444', 'a descriptor id'); + assert.equal(registry.resolveSendTarget('444', 'discord-a'), 'discord:g:444', 'a raw id, on its server'); + assert.equal(registry.resolveSendTarget('444', 'discord-b'), undefined, 'not on another server'); + assert.equal(registry.resolveSendTarget('555'), undefined, 'a raw id two servers share'); + assert.equal(registry.resolveSendTarget('555', 'discord-b'), 'discord:h:555', 'the server settles it'); + assert.equal(registry.resolveSendTarget('999', 'discord-a'), undefined, 'an unknown id'); + assert.equal(openCalls.length, opensBefore, 'a lookup opens nothing'); +}); + +test("openForSpeech opens a closed channel nobody decided to keep closed, and leaves every other one as it is", async () => { + const { registry, openCalls } = makeRegistry({ delivered: true }); + const ids = ['undecided', 'agent-closed', 'decided-open', 'tuned-out', 'open-now']; + await registry.handleChanged('discord', { + added: ids.map((id) => ({ id, type: 'discord', label: `#${id}`, direction: 'bidirectional' })), + } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + await registry.handleChannelToolCall('channel_close', { channelId: 'agent-closed' }, agent); + await registry.handleChannelToolCall('channel_open', { channelId: 'open-now' }, agent); + await registry.handleChannelToolCall('channel_open', { channelId: 'tuned-out' }, agent); + registry.enterTuneOut('discord', 'tuned-out', { epochId: 'e1', cadenceSeconds: 600, backlogCap: 20, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + // Decided open, with its transport down. + await registry.handleChannelToolCall('channel_open', { channelId: 'decided-open' }, agent); + (registry as unknown as { findChannelEntry(id: string): { open: boolean } }).findChannelEntry('decided-open').open = false; + const opensBefore = openCalls.length; + + assert.equal(await registry.openForSpeech('discord', 'undecided', 'opened-by-reply'), 'opened'); + assert.equal(await registry.openForSpeech('discord', 'agent-closed', 'opened-by-reply'), 'not-opened'); + assert.equal(await registry.openForSpeech('discord', 'decided-open', 'opened-by-reply'), 'not-opened'); + assert.equal(await registry.openForSpeech('discord', 'tuned-out', 'opened-by-reply'), 'not-opened'); + assert.equal(await registry.openForSpeech('discord', 'open-now', 'opened-by-reply'), 'not-opened'); + assert.equal(await registry.openForSpeech('discord', 'nowhere', 'opened-by-reply'), 'unknown-channel'); + + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['undecided']); + assert.equal(registry.getDesiredState('discord', 'undecided'), 'open'); + assert.equal(registry.getDesiredState('discord', 'tuned-out'), 'tuned-out', 'the tune-out stands'); + assert.equal(registry.closedByAgent('discord', 'agent-closed'), 'closed'); +}); + test('policy admissions are announced to the agent exactly once, as one batch', async () => { - const autoOpened: Array<{ source: string; channels: Array<{ channelId: string }> }> = []; + const autoOpened: Array<{ conversationId?: string; source: string; channels: Array<{ channelId: string }> }> = []; const { registry } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); registry.setSubscriptionPolicy('discord', ['chan-1', 'chan-2']); @@ -594,6 +644,22 @@ test('policy admissions are announced to the agent exactly once, as one batch', assert.equal(autoOpened.length, 1, 'a durable decision is never re-announced'); }); +test('opened-by-delivery is announced to the delivering agent', async () => { + const autoOpened: Array<{ conversationId?: string; source: string; channels: Array<{ channelId: string }> }> = []; + const { registry, lookup } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); + + seedRegistered(registry, 'discord', 'dm-alice'); + registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + lookup('dm-alice')!.open = false; + + await registry.routeSpeech('sol', 'reply', 'dm-alice'); + + assert.equal(autoOpened.length, 1); + assert.equal(autoOpened[0]!.source, 'opened-by-delivery'); + assert.equal(autoOpened[0]!.conversationId, 'sol'); + assert.deepEqual(autoOpened[0]!.channels.map(c => c.channelId), ['dm-alice']); +}); + test('resolveProseTarget matches the name segment of suffixed labels (#fable vs "#fable (guild)")', async () => { const { registry } = makeRegistry({ delivered: true }); await registry.handleChanged('discord', { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 012537d3b..1587191aa 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1418,8 +1418,8 @@ describe('explicit prose routing', () => { await framework.stop(); }); - /** A registry stub for the closed-channel note: #cafe is closed, laria's DM - * is closed and the registry knows it as a DM, and #alpha is open. */ + /** A registry stub for the closed-channel note: the agent closed #cafe, + * laria's DM is closed and nobody decided about it, and #alpha is open. */ function closedNoteRegistry(framework: AgentFramework, held?: Promise | ((locus: string | null) => Promise | undefined)) { const routed: Array<{ text: string; locus: string | null }> = []; const known: Record = { @@ -1439,7 +1439,8 @@ describe('explicit prose routing', () => { resolveLocus: () => 'chan-alpha', getDefaultPublishChannel: () => null, isChannelOpen: (id: string) => id === 'chan-alpha', - isDirectMessageChannel: (id: string) => id === 'discord:dm:99', + closedByAgent: (_server: string, id: string) => (id === 'discord:g:cafe' ? 'closed' : undefined), + canOpenChannels: () => true, getDescriptor: (id: string) => (labels[id] ? { label: labels[id] } : undefined), getChannelServerId: () => 'discord', getChannelTools: () => [], @@ -1450,7 +1451,7 @@ describe('explicit prose routing', () => { return routed; } - const CAFE_NOTE = "[closed channel] #cafe (discord:g:cafe) stays closed: speaking into a closed channel doesn't open it. " + + const CAFE_NOTE = "[closed channel] #cafe (discord:g:cafe) stays closed: you closed it, and your words there don't open it. " + 'Messages there that address you, such as an @-mention, still reach you; ' + 'channel_open with channelId "discord:g:cafe" and serverId "discord" opens it. ' + 'To stop this note, set closed_channel_note to false with agent_settings.'; @@ -1460,7 +1461,7 @@ describe('explicit prose routing', () => { .flatMap((m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : []))); } - it("speech that stays in a closed channel ends the turn with the closed-channel note alone, and a DM's or an open channel's gets none", async () => { + it("speech into a channel the agent closed ends the turn with the closed-channel note alone, and anywhere else gets none", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#cafe hello there\n>>@laria and you\n>>#alpha and everyone' }, ] as ContentBlock[])); @@ -1608,53 +1609,7 @@ describe('explicit prose routing', () => { } }); - it('a turn that fails after its speech stayed in a closed channel still gets the note', async () => { - // Its speech is delivered at the tool call, and the round after the tool - // fails for good instead of completing: a 400, which the framework doesn't retry. - membrane.pushResponse(createMockResponse([ - { type: 'text', text: '>>#cafe heading out now' }, - { type: 'tool_use', id: 'f1', name: 'robot--move', input: { dir: 'out' } }, - ] as ContentBlock[], 'tool_use')); - membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); - const routed = closedNoteRegistry(framework); - const makeStream = membrane.streamYielding.bind(membrane); - let streams = 0; - membrane.streamYielding = (request, options) => { - const stream = makeStream(request, options); - streams++; - const iterate = stream[Symbol.asyncIterator].bind(stream); - stream[Symbol.asyncIterator] = async function* () { - for await (const event of { [Symbol.asyncIterator]: iterate }) { - if (event.type === 'complete') { - yield { - type: 'error' as const, - error: new MembraneError({ - type: 'invalid_request', retryable: false, httpStatus: 400, - message: 'synthetic invalid request', rawError: { status: 400 }, rawRequest: request, - }), - }; - return; - } - yield event; - } - }; - return stream; - }; - - trigger(framework); - await framework.runUntilIdle(); - for (const end = Date.now() + 5000; !windowTexts(framework).includes(CAFE_NOTE) && Date.now() < end;) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - - assert.equal(streams, 1, 'the failure was not retried'); - assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); - assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE]); - await framework.stop(); - }); - - it("in hybrid mode, speech that stays in a closed channel ends the turn's delivery line with the note", async () => { + it("in hybrid mode, speech into a channel the agent closed ends the turn's delivery line with the note", async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>>#cafe hello there' }, ] as ContentBlock[])); diff --git a/test/mcpl-rejection-log-lines.test.ts b/test/mcpl-rejection-log-lines.test.ts index d31c55628..31e94cede 100644 --- a/test/mcpl-rejection-log-lines.test.ts +++ b/test/mcpl-rejection-log-lines.test.ts @@ -244,22 +244,16 @@ test('speech routed into a closed channel names the channel as one field on each sendChannelsPublish: async () => ({ delivered: true }), sendChannelsOpen: async () => ({}), }; - // A store that can't record the channel's answered mark, so its line prints too. - const store = { - registerState: () => {}, - getStateJson: () => null, - appendToStateJson: () => { throw new Error('disk full\nreason=forged'); }, - }; const registry = new ChannelRegistry( - { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, { store: store as never }, + { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, {}, ); (registry as unknown as { channels: Map }).channels.set(`editor:${FORGED_CHANNEL}`, { serverId: 'editor', descriptor: { id: FORGED_CHANNEL, type: 'discord', label: 'chat' }, open: false, }); const routed = await registry.routeSpeech('agent', 'hello', FORGED_CHANNEL); - assert.equal(routed?.delivered, true, 'the publish stands when the mark is not recorded'); - assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]') || l.startsWith('[channel]')), [ - `[channel] answered mark for ${field(FORGED_CHANNEL)} not recorded: ${logDetail('disk full\nreason=forged')}`, + assert.equal(routed?.delivered, true); + assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]')), [ + `[routeSpeech] agent: locus ${field(FORGED_CHANNEL)} was closed — opened by delivery (speech implies engagement)`, `[routeSpeech] agent: routed 5 chars -> ${field(FORGED_CHANNEL)} (server=editor, delivered=true)`, ]); }); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 96ed57edf..84e659649 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1500,12 +1500,11 @@ describe('present while acting', () => { framework.mcplServerConfigs = new Map([['chat', { id: 'chat', toolPrefix: 'chat' }]]); framework.mcplToolFeatureSets = new Map(); framework.checkpointManager = null; - // Speaking opens nothing: a send resolves its target and marks it answered. + // The send resolves its target, and opens it if it's closed and the agent + // didn't close it: here it's open, so its answer carries no note either. framework.channelRegistry = { - decisionSequence: () => 0, resolveSendTarget: (target: string) => target, - noteAnswered: () => {}, - // An open channel, so the send's answer carries no closed-channel note. + openForSpeech: async () => 'not-opened', isChannelOpen: () => true, }; framework.turnRouting = new Map([['assistant', sending]]); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index e5e2a4aaa..69dfc49eb 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -1,14 +1,15 @@ /** - * Speaking never opens a channel: an explicit send tool's successful call - * posts (the server sends it) and leaves the channel's lifecycle as the agent - * left it, with no "[channels] Now open" notice: a channel the agent closed - * stays closed, a channel whose open failed isn't opened again, and a - * tuned-out channel stays tuned out. The call still - * records the channel as engaged this turn, by its descriptor id, for the - * mid-turn re-pin. Where the words land in a closed channel that isn't a DM, - * the send's answer, or the turn's delivery line for routed speech, says the - * channel stays closed, what still reaches the agent from it, and how to open - * it, until the agent turns that note off with agent_settings. + * Speaking never undoes the agent's own decision to keep a channel closed: an + * explicit send tool's successful call posts (the server sends it), and opens + * a closed channel, as channel_open does, only where nobody decided to keep it + * closed. A channel the agent closed or declined stays closed, a channel whose + * open failed isn't opened again, and a tuned-out channel stays tuned out. + * The call records the channel as engaged this turn, by its descriptor id, + * for the mid-turn re-pin. Where the words land in a channel the agent closed + * or declined itself, the send's answer, or the turn's delivery line for + * routed speech, says the channel stays closed, what still reaches the agent + * from it, and how to open it, until the agent turns that note off with + * agent_settings. */ import { test } from 'node:test'; @@ -26,8 +27,8 @@ import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; const channelId = 'discord:g1:444555'; const origin = { kind: 'agent' as const, agentName: 'sol' }; -/** The note a send's answer carries when it lands in the closed #upkeep. */ -const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: speaking into a closed channel doesn't open it. ` + +/** The note a send's answer carries when it lands in #upkeep, which the agent closed. */ +const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: you closed it, and your words there don't open it. ` + 'Messages there that address you, such as an @-mention, still reach you; ' + `channel_open with channelId "${channelId}" and serverId "discord" opens it. ` + 'To stop this note, set closed_channel_note to false with agent_settings.'; @@ -186,20 +187,20 @@ test("a note that can't be worked out leaves the delivered words as they were, a assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: store closed']); framework.store = store; - // The registry can't say whether the channel is a DM, and then a lookup + // The registry can't say who closed the channel, and then a lookup // throws something that isn't an Error at all. - const isDirectMessageChannel = registry.isDirectMessageChannel; - registry.isDirectMessageChannel = () => { throw new Error('lookup failed'); }; + const closedByAgent = registry.closedByAgent; + registry.closedByAgent = () => { throw new Error('lookup failed'); }; sent = await reply(); assert.equal(sent.success, true); assert.equal(sent.data, answer); assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: lookup failed']); - registry.isDirectMessageChannel = () => { throw undefined; }; + registry.closedByAgent = () => { throw undefined; }; sent = await reply(); assert.equal(sent.success, true); assert.equal(sent.data, answer); assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: undefined']); - registry.isDirectMessageChannel = isDirectMessageChannel; + registry.closedByAgent = closedByAgent; // Routed speech's delivery line stands without the note. const lines: string[] = []; @@ -229,7 +230,7 @@ test('a send the server refuses carries no closed-channel note', async () => { assert.equal(refused.error, 'Missing Access in channel 444555', "the server's refusal, and nothing about the channel"); }); -test('a send into an open channel, a DM the registry knows, or by send_dm carries no closed-channel note', async () => { +test('a send into an open channel, or by send_dm, carries no closed-channel note', async () => { { const { registry, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); @@ -237,43 +238,43 @@ test('a send into an open channel, a DM the registry knows, or by send_dm carrie } { // send_dm sends to a DM by what the tool is, so its answer carries no - // note even into #upkeep, a channel the registry doesn't know as a DM. - const { reply } = await harness(); + // note even into #upkeep, which the agent closed. + const { registry, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); } - { - // One the registry knows as a DM by its metadata. - const { registry, reply, setSentTo } = await harness(); - await registry.handleChanged('discord', { - added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', - address: { channelId: '777' }, metadata: { channelType: 'dm' } }], - } as never); - setSentTo('777'); - const { data, engagedAtResult } = await reply(); - assert.deepEqual(engagedAtResult, ['discord:dm:777']); - assert.ok(!data.includes('[closed channel]'), 'registry DM'); - } - { - // One the registry knows as a DM by its metadata alone: neither its id nor - // its label has a DM's shape. - const { registry, reply, setSentTo } = await harness(); - await registry.handleChanged('discord', { - added: [{ id: 'discord:private-room-42', type: 'discord', label: 'Tess', direction: 'bidirectional', - address: { channelId: '42' }, metadata: { channelType: 'dm' } }], - } as never); - setSentTo('42'); - const { data, engagedAtResult } = await reply(); - assert.deepEqual(engagedAtResult, ['discord:private-room-42']); - assert.ok(!data.includes('[closed channel]'), 'registry DM by metadata'); - } }); -test("routed speech that stays in a closed channel says so in the turn's delivery line", async () => { - const { registry, framework } = await harness(); +test('a send into a closed channel nobody decided about opens it, as channel_open does, and says so', async () => { + const { registry, opens, windowMessages, reply, setSentTo } = await harness(); await registry.handleChanged('discord', { added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', address: { channelId: '777' }, metadata: { channelType: 'dm' } }], } as never); + // #upkeep, and a DM that woke the agent: both registered closed, by default. + for (const [raw, id, label] of [['444555', channelId, '#upkeep'], ['777', 'discord:dm:777', 'Tess']] as const) { + setSentTo(raw); + const { success, data, engagedAtResult } = await reply(); + assert.equal(success, true); + assert.ok(!data.includes('[closed channel]'), `${id}: no note where the send opens the channel`); + assert.equal(engagedAtResult.at(-1), id, 'recorded as engaged'); + assert.equal(registry.isChannelOpen(id), true, `${id} opened`); + assert.equal(registry.getDesiredState('discord', id), 'open'); + assert.equal(windowMessages.at(-1), + `[channels] Now open: ${label} (${id}) — your own message into the closed channel engaged it. ` + + 'Ongoing traffic from this channel will reach you. If you don\'t want it, use channel_close: a channel you close ' + + 'stays closed until you open it with channel_open, and neither speaking there nor policy reopens it.'); + } + assert.deepEqual(opens.map((o) => o.channelId), [channelId, 'discord:dm:777']); +}); + +test("routed speech into a channel the agent closed says so in the turn's delivery line", async () => { + const { registry, framework } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:777888', type: 'discord', label: '#lobby', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '777888' } }], + } as never); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); const lines: string[] = []; const agent = { name: 'sol', @@ -289,18 +290,21 @@ test("routed speech that stays in a closed channel says so in the turn's deliver // The same words as a send's note, without its tag. const note = UPKEEP_NOTE.slice('[closed channel] '.length); assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId}) · ${note}`); - // Two closed channels in one turn: a note for each, and how to stop them once. + // Two channels the agent closed in one turn: a note for each, and how to stop them once. await registry.handleChanged('discord', { added: [{ id: 'discord:g1:555666', type: 'discord', label: '#standup', direction: 'bidirectional', address: { guildId: 'g1', channelId: '555666' } }], } as never); + assert.equal((await registry.handleChannelToolCall( + 'channel_decline', { channelId: 'discord:g1:555666', messageId: 'm1' }, origin)).success, true); const stop = 'To stop this note, set closed_channel_note to false with agent_settings.'; const upkeepNote = note.slice(0, -(stop.length + 1)); - const standupNote = upkeepNote.replaceAll('#upkeep', '#standup').replaceAll(channelId, 'discord:g1:555666'); + const standupNote = upkeepNote.replaceAll('#upkeep', '#standup').replaceAll(channelId, 'discord:g1:555666') + .replace('you closed it', 'you declined it'); assert.equal(receipt(channelId, 'discord:g1:555666'), `[delivered] plain speech → #upkeep (${channelId}) · #standup (discord:g1:555666) · ${upkeepNote} ${standupNote} ${stop}`); - // A DM says nothing more. - assert.equal(receipt('discord:dm:777'), '[delivered] plain speech → #Tess (discord:dm:777)'); + // A closed channel nobody decided about says nothing more: speech opens it. + assert.equal(receipt('discord:g1:777888'), '[delivered] plain speech → #lobby (discord:g1:777888)'); // Nor does an open channel, or any channel once the agent has turned the note off. framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); assert.equal(receipt(channelId), `[delivered] plain speech → #upkeep (${channelId})`); @@ -325,12 +329,6 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); assert.deepEqual(engagedAtResult, [channelId], 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); - - // The send marked the channel answered: when the push lane reports its - // next message, a turn with no trigger speaks there. - assert.equal(registry.getDefaultPublishChannel(), null); - registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); - assert.equal(registry.getDefaultPublishChannel(), channelId, 'the send answered the channel'); }); test('a reply_message into a channel decided open whose open failed retries no open', async () => { @@ -367,8 +365,8 @@ test('a reply_message into a tuned-out channel leaves the tune-out in place', as assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); }); -test('a reply_message still in flight when the agent closes its channel marks nothing', async () => { - const { registry, reply, setHold } = await harness(); +test('a reply_message still in flight when the agent closes its channel leaves it closed', async () => { + const { registry, opens, reply, setHold } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); let release!: () => void; setHold(new Promise((resolve) => { release = resolve; })); @@ -376,11 +374,12 @@ test('a reply_message still in flight when the agent closes its channel marks no // The same round closes the channel while the reply is in flight. assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); release(); - const { success } = await replying; + const opensBefore = opens.length; + const { success, data } = await replying; assert.equal(success, true); - assert.equal(registry.isChannelOpen(channelId), false, 'the channel stays closed'); - registry.notePushDelivered('discord', channelId, 'm2', undefined, ['chat:mention']); - assert.equal(registry.getDefaultPublishChannel(), null, 'the close, made while the reply was in flight, stands'); + assert.equal(registry.isChannelOpen(channelId), false, 'the close, made while the reply was in flight, stands'); + assert.equal(opens.length, opensBefore, 'the send opened nothing'); + assert.ok(data.endsWith(UPKEEP_NOTE), 'and its answer says the channel stays closed'); }); test("closed_channel_note is an agent_settings key, kept in the framework's state beside the others across a restart", async () => { @@ -413,7 +412,7 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat const tool = framework.getAllTools().find((t) => t.name === 'agent_settings')!; const property = (tool.inputSchema.properties as Record).closed_channel_note; assert.equal(property.type, 'boolean'); - assert.match(property.description, /closed channel that isn't a DM/); + assert.match(property.description, /a channel you closed or declined/); assert.equal(settings(framework, { action: 'get' }).data?.closed_channel_note, true, 'on by default'); // Another framework-owned setting, kept in the same state. @@ -438,11 +437,11 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat } }); -test("a sleep announced into a closed channel says only what addresses the agent reaches it, and the result carries the note", async () => { - const { registry, framework, published } = await harness(); +test("a sleep announced into a channel the agent closed says only what addresses it reaches it; anywhere else it opens as speech does", async () => { + const { registry, framework, published, opens } = await harness(); await registry.handleChanged('discord', { - added: [{ id: 'discord:dm:777', type: 'discord', label: 'Tess', direction: 'bidirectional', - address: { channelId: '777' }, metadata: { channelType: 'dm' } }], + added: [{ id: 'discord:g1:777888', type: 'discord', label: '#lobby', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '777888' } }], } as never); framework.eventGate = { setSleep: (seconds: number) => ({ until: Date.parse('2026-10-16T04:00:00Z') + seconds * 1000 }) }; framework.activeTurnTriggers = new Map(); @@ -472,108 +471,94 @@ test("a sleep announced into a closed channel says only what addresses the agent const quiet = "💤 Going quiet for 10m. I'll still see messages, but won't respond until I wake."; const addressed = "💤 Going quiet for 10m. I'll still see messages addressed to me, but won't respond until I wake."; - // The turn's locus is the closed #upkeep. + // The turn's locus is #upkeep, which the agent closed. + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); routing.locus = channelId; let slept = await sleep({ seconds: 600 }); assert.equal(slept.success, true); assert.equal(slept.endTurn, true); - assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); + assert.equal(slept.data.closedChannelNote, undefined, 'the sleep result carries no note'); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + assert.equal(registry.isChannelOpen(channelId), false, 'it stays closed'); - // The agent's own words are posted as they are, and the note still comes. + // The agent's own words are posted as they are. slept = await sleep({ seconds: 600, message: 'back soon' }); - assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); assert.deepEqual(published.splice(0), [{ channelId, text: 'back soon' }]); // With the note turned off, the announcement still says what reaches the agent. framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); framework.closedChannelNoteSettingsExtension().reset('sol'); - // A DM, and an open channel: every message still reaches the agent, and no note. - routing.locus = 'discord:dm:777'; - slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); - assert.deepEqual(published.splice(0), [{ channelId: 'discord:dm:777', text: quiet }]); - assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); - routing.locus = channelId; - slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); - assert.deepEqual(published.splice(0), [{ channelId, text: quiet }]); - assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); - - // A locus the registry doesn't know: the post fails, as before, and there's no note. - routing.locus = 'discord:g1:999999'; - slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); - assert.deepEqual(routedTo.splice(0).slice(-1), ['discord:g1:999999']); - assert.deepEqual(published.splice(0), [], 'nothing reached a channel'); - // No locus at all: routed as before, with nothing to post to, and no note. - routing.locus = null; - slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); - assert.deepEqual(routedTo.splice(0), [null]); - assert.deepEqual(published.splice(0), []); - routing.locus = channelId; - - // When whether the channel stays closed can't be read, the words are ones - // true either way, and there's no note. - const isDm = registry.isDirectMessageChannel.bind(registry); - registry.isDirectMessageChannel = () => { throw new Error('the registry is unreadable'); }; + // When who closed the channel can't be read, the words are ones true either way. + const closedByAgent = registry.closedByAgent.bind(registry); + registry.closedByAgent = () => { throw new Error('the registry is unreadable'); }; slept = await sleep({ seconds: 600 }); - registry.isDirectMessageChannel = isDm; - assert.equal(slept.data.closedChannelNote, undefined); + registry.closedByAgent = closedByAgent; assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); - // A conversation agent can't open the channel, so it gets no note; the words - // for people follow the channel. + // A conversation agent's announcement reads the same: the words follow the channel. framework.conversationAgentHomes.set('sol', channelId); slept = await sleep({ seconds: 600 }); framework.conversationAgentHomes.delete('sol'); - assert.equal(slept.data.closedChannelNote, undefined); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); // Hybrid routing announces to the locus as locus mode does. framework.agents.set('sol', { proseRouting: 'hybrid' }); slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + framework.agents.set('sol', {}); + + // A closed channel nobody decided about: the announcement opens it, as speech does, so all of it reaches the agent. + routing.locus = 'discord:g1:777888'; + const opensBefore = opens.length; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:777888', text: quiet }]); + assert.deepEqual(opens.slice(opensBefore).map((o) => o.channelId), ['discord:g1:777888']); + assert.equal(registry.isChannelOpen('discord:g1:777888'), true); + // And an open one. + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:777888', text: quiet }]); routedTo.splice(0); - // Prose routing off, or a silent turn: nothing is posted, and no note. + // A locus the registry doesn't know: the post fails, as before. + routing.locus = 'discord:g1:999999'; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(routedTo.splice(0), ['discord:g1:999999']); + assert.deepEqual(published.splice(0), [], 'nothing reached a channel'); + // No locus at all: routed as before, with nothing to post to. + routing.locus = null; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(routedTo.splice(0), [null]); + assert.deepEqual(published.splice(0), []); + routing.locus = channelId; + + // Prose routing off, a silent turn, or no announcement: nothing is routed. framework.agents.set('sol', { proseRouting: 'disabled' }); slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); framework.agents.set('sol', {}); framework.activeTurnTriggers.set('sol', { suppressProse: true }); slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); framework.activeTurnTriggers.delete('sol'); + slept = await sleep({ seconds: 600, announce: false }); assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); - // No announcement, no note: one turned off, and explicit mode with no target this turn. - slept = await sleep({ seconds: 600, announce: false }); - assert.equal(slept.data.closedChannelNote, undefined); + // Explicit mode announces only to the turn's target: none, or an empty one, routes nothing. framework.agents.set('sol', { proseRouting: 'explicit' }); slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); - // An empty target is none. routing.pin = ''; slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, undefined); assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); assert.deepEqual(published, [], 'nothing was posted'); - // Explicit mode announces to the turn's target, with the note. routing.pin = channelId; slept = await sleep({ seconds: 600 }); - assert.equal(slept.data.closedChannelNote, UPKEEP_NOTE); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); }); test('a conversation agent gets no note on its send: channel_open is refused to it', async () => { - const { framework, reply, windowMessages } = await harness(); + const { registry, framework, reply, windowMessages } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); framework.conversationAgentHomes.set('sol', channelId); const { success, data } = await reply(); assert.equal(success, true); @@ -582,7 +567,8 @@ test('a conversation agent gets no note on its send: channel_open is refused to }); test("channel_publish never opened a channel, so its answer carries no note", async () => { - const { framework, published, windowMessages } = await harness(); + const { registry, framework, published, windowMessages } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); const result = await new Promise<{ success: boolean; data?: unknown }>((resolve) => { framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown } }) => { if (event.type === 'tool-result' && event.callId === 'call-publish') resolve(event.result); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 8ee5fbd1b..6aa8f1e59 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -47,7 +47,6 @@ function internals(framework: AgentFramework) { channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; handleMcplChannelIncoming(event: unknown): Promise; - closedChannelNotes(agentName: string, channelIds: Iterable): string[]; }; } @@ -301,7 +300,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); assert.match(t2, /no single-word target reaches this channel unambiguously/); - assert.match(t2, /check whether discord exposes a send tool that can address it\. Note: follow-ups to your reply will NOT reach you/, + assert.match(t2, /check whether discord exposes a send tool that can address it\.\n/, 'replying needs no join, and the hint promises no tool'); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'joining stays available'); await fw2.stop(); @@ -321,9 +320,9 @@ describe('Trunk channel routing (item-3 redux)', () => { }); it('a direct message carries no invitation: all of a DM reaches the agent, open or closed', async () => { - // Speaking never opens a channel, so a DM the agent has answered stays closed. An invitation - // on each DM would repeat a block whose premise is false there: there is no other traffic - // to miss, and nothing to join. + // A DM registers closed, and stays closed until the agent answers it, or for good once the + // agent closes it. An invitation on its messages would repeat a block whose premise is false + // there: there is no other traffic to miss, and nothing to join. const framework = await makeFramework(); const i = internals(framework); i.channelRegistry = { @@ -393,53 +392,6 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a whisper's chat:dm tag doesn't make its channel a DM: words left in that closed channel still get the note", async () => { - // A chat:dm tag describes one message. eidoverse-worlds tags a whisper inside - // its shared world channel that way, on either delivery path. - const framework = await makeFramework(); - const i = internals(framework); - const world = 'eido:world:plaza'; - i.channelRegistry = { - ensureChannelRegistered: () => {}, - isChannelOpen: () => false, - isDirectMessageChannel: () => false, - getDesiredState: () => undefined, - getDescriptor: () => ({ label: '#plaza', capabilities: { history: { maxMessages: 80 } } }), - getChannelServerId: () => 'eido', - proseTargetFor: () => '#plaza', - stopAll: () => {}, - }; - const whisper = ['chat:dm', 'chat:private', 'chat:addressed']; - i.handleMcplPushEvent({ - type: 'mcpl:push-event', - serverId: 'eido', - featureSet: 'eido.chat', - eventId: 'eido_w1', - content: [{ type: 'text', text: 'mira (whispering): over here' }], - origin: { source: 'eido', messageId: 'w1', mcplChannelId: world, authorName: 'mira' }, - tags: whisper, - timestamp: new Date().toISOString(), - inferenceId: 'iw1', - triggerInference: false, - }); - await i.handleMcplChannelIncoming({ - type: 'mcpl:channel-incoming', - serverId: 'eido', - channelId: world, - messageId: 'w2', - author: { id: 'U9', name: 'mira' }, - content: [{ type: 'text', text: 'mira (whispering): and again' }], - timestamp: new Date().toISOString(), - metadata: {}, - tags: whisper, - triggerInference: false, - }); - const notes = i.closedChannelNotes('scout', [world]); - assert.equal(notes.length, 1, 'the note is still given there'); - assert.match(notes[0]!, /^#plaza \(eido:world:plaza\) stays closed/); - await framework.stop(); - }); - it('a channel-incoming trunk turn records its triggering channel', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); From b09f6d3750831b8b07c8307bb9089884b41f0300 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Fri, 16 Oct 2026 23:59:18 -0400 Subject: [PATCH 17/37] test(channels): the note offers no channel_open on a server without channels.lifecycle (R1-5) A send into a channel the agent closed on a server whose grant lacks channels.lifecycle: the close is still recorded as the agent's decision (handleToolClose sets it before the grant check), and the send's note ends at 'still reach you.', since channel_open can't open anything there. --- test/send-into-closed-channel.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 69dfc49eb..fd15cb5c8 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -139,6 +139,7 @@ async function harness() { setHold: (p: Promise | null) => { hold = p; }, setSentTo: (raw: string) => { sentTo = raw; }, setRefuseSends: (v: boolean) => { refuseSends = v; }, + setGrant: (grant: CapabilityGrant) => { server.grant = grant; }, state: () => frameworkState as { closedChannelNotes?: Record } | null, }; } @@ -220,6 +221,19 @@ test("a note that can't be worked out leaves the delivered words as they were, a } }); +test("on a server whose grant lacks channels.lifecycle, the note doesn't offer channel_open, which can't open anything there", async () => { + const { registry, reply, setGrant } = await harness(); + setGrant(new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), [])); + // The close is the agent's decision on record, though the server can't be told. + await registry.handleChannelToolCall('channel_close', { channelId }, origin); + assert.equal(registry.closedByAgent('discord', channelId), 'closed'); + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(data.endsWith(`\n[closed channel] #upkeep (${channelId}) stays closed: you closed it, and your words there don't open it. ` + + 'Messages there that address you, such as an @-mention, still reach you. ' + + 'To stop this note, set closed_channel_note to false with agent_settings.'), data); +}); + test('a send the server refuses carries no closed-channel note', async () => { const { registry, reply, setRefuseSends } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); From e9ef64b0a944c7ed189171ffe0bcad8e212bfa4b Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:02:38 -0400 Subject: [PATCH 18/37] test(channels): a channel whose close the server refused gets no note; sleep reads the channel's server directly - send-into-closed-channel: the agent's channel_close is recorded before the server's channels/close, which the server refuses here, so the record says the agent closed the channel while its transport stays open and its traffic flows. A send there carries no note (closedChannelNoteFor's isChannelOpen check), and opens nothing. - The sleep announcement's words: the getDescriptor check before reading whether the channel stays closed is dropped. closedChannelNoteFor already returns nothing for a channel the registry doesn't know (no server id), so the check added a branch and no behaviour. getChannelServerId returns null itself, so its '?? null' goes too. --- src/framework.ts | 5 ++--- test/send-into-closed-channel.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 5a877a535..380e16e06 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18854,10 +18854,9 @@ export class AgentFramework { // fire-and-forget. Those words are true in an open channel too, so // they're the ones used when the channel's state can't be read. let staysClosed = false; - if (destination && this.channelRegistry?.getDescriptor(destination)) { + if (destination && this.channelRegistry) { try { - staysClosed = - this.closedChannelNoteFor(destination, this.channelRegistry.getChannelServerId(destination) ?? null) !== undefined; + staysClosed = this.closedChannelNoteFor(destination, this.channelRegistry.getChannelServerId(destination)) !== undefined; } catch (err) { staysClosed = true; console.error(`[sleep] ${agentName}: whether ${destination} stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index fd15cb5c8..90725c457 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -45,6 +45,8 @@ async function harness() { let sentTo = '444555'; // While set, the server refuses the send, as discord-mcpl does without Send Messages. let refuseSends = false; + // While set, the server refuses channels/close. + let refuseCloses = false; const published: Array<{ channelId: string; text: string }> = []; const server = { id: 'discord', @@ -63,6 +65,7 @@ async function harness() { return {}; }, async sendChannelsClose() { + if (refuseCloses) throw new Error('close refused by server'); return {}; }, // What routed speech, such as a sleep announcement, posts. @@ -139,6 +142,7 @@ async function harness() { setHold: (p: Promise | null) => { hold = p; }, setSentTo: (raw: string) => { sentTo = raw; }, setRefuseSends: (v: boolean) => { refuseSends = v; }, + setRefuseCloses: (v: boolean) => { refuseCloses = v; }, setGrant: (grant: CapabilityGrant) => { server.grant = grant; }, state: () => frameworkState as { closedChannelNotes?: Record } | null, }; @@ -234,6 +238,23 @@ test("on a server whose grant lacks channels.lifecycle, the note doesn't offer c 'To stop this note, set closed_channel_note to false with agent_settings.'), data); }); +test("a channel the agent closed whose server refused the close is still open, so a send there carries no note", async () => { + const { registry, opens, reply, setRefuseCloses } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + setRefuseCloses(true); + const closed = await registry.handleChannelToolCall('channel_close', { channelId }, origin); + assert.equal(closed.success, false); + // The record says the agent closed it; its traffic still flows. + assert.equal(registry.closedByAgent('discord', channelId), 'closed'); + assert.equal(registry.isChannelOpen(channelId), true); + const opensBefore = opens.length; + + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(!data.includes('[closed channel]'), data); + assert.equal(opens.length, opensBefore, 'an open channel is opened by nothing'); +}); + test('a send the server refuses carries no closed-channel note', async () => { const { registry, reply, setRefuseSends } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); From 756e39bddb0feaf97528949ab7daf7d50a9f2452 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:05:21 -0400 Subject: [PATCH 19/37] test(channels): pin channel_close's promise; the receipt's comment says the narrow rule - channel-registry-routing: channel_close's description ends with the promise the narrow rule keeps (It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it.), so a merge that rewrites the description (#406 does) can't drop it unseen. - The [delivered] receipt's comment said 'a closed channel that isn't a DM'; it's a channel the agent closed or declined. - send-into-closed-channel: the first note test's title says 'a channel the agent closed', which is what it sets up. --- src/framework.ts | 4 ++-- test/channel-registry-routing.test.ts | 7 +++++++ test/send-into-closed-channel.test.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 380e16e06..564ac2343 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10952,8 +10952,8 @@ export class AgentFramework { ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` : ''; - // Words that stayed in a closed channel that isn't a DM say so, unless - // the agent has turned the note off. + // Words that stayed in a channel the agent closed or declined say so, + // unless the agent has turned the note off. const closedNotes = this.closedChannelNotes(agent.name, seen); const closedNote = closedNotes.length > 0 ? ` · ${closedNotes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 0970a86e1..881c654d4 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -574,6 +574,13 @@ test("a machine's close of a channel with no record leaves it the default it was assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['lazy-1']); }); +test("channel_close's description says the close stands against speech and policy", () => { + const { registry } = makeRegistry({ delivered: true }); + const close = registry.getChannelTools().find((t) => t.name === 'channel_close')!; + assert.match(close.description, + /It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it\.$/); +}); + test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = (id: string, raw: string) => ({ diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 90725c457..65fbc7102 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -148,7 +148,7 @@ async function harness() { }; } -test("a send that stays in a closed channel says so, what still reaches the agent, and how to open it, until they turn the note off", async () => { +test("a send into a channel the agent closed says so, what still reaches the agent, and how to open it, until they turn the note off", async () => { const { registry, framework, reply, state } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); From b49cbfc7f8272d454fdac3118d62f13cee90277d Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:10:08 -0400 Subject: [PATCH 20/37] fix(channels): speech opens a channel decided open whose open failed, as main did The rule I confirmed with Ada-1017 (room-203 #120102, #120113) is that speech opens a closed channel as on main unless its record is the agent's own close or decline. openForSpeech also left a channel decided open whose transport was closed alone, which kept a consequence of the broad rule: on main, routeSpeech's open and openIfClosedForSend both reopened such a channel (its open had failed, or reconciliation hadn't reached it). Now it opens again, keeping the decision's source (setDesiredState leaves an 'agent-tool' open as it is). A tune-out still stands: it's the agent's decision, and speech into it ended it on main's send path. Tests: openForSpeech's cases now take a tune-out and a decided-open channel with their transports down (the first stays as it is, the second opens); the send test for a channel decided open whose open failed now expects the send to open it again, with main's notice and no note. The fragment's openForSpeech line drops 'decided open with its transport down'. --- changelog.d/agent-close-holds.breaking.md | 2 +- src/mcpl/channel-registry.ts | 16 +++++++++------- test/channel-registry-routing.test.ts | 11 +++++++---- test/send-into-closed-channel.test.ts | 14 +++++++++----- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index d4c85ad3d..ff3032e7d 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -34,7 +34,7 @@ `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went to, from a composite or raw id, and opens nothing. `openForSpeech(serverId, channelId, source)` opens it as the framework's send path and `routeSpeech` do: a closed channel opens unless the - agent closed or declined it, it's tuned out, or it's decided open with its transport down. + agent closed or declined it, or it's tuned out. `closedByAgent(serverId, channelId)` and `canOpenChannels(serverId)` are new. - **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so the first request after the upgrade that carries those tools misses the cache from its tool diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 609e7a638..e5db893a2 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2670,12 +2670,14 @@ export class ChannelRegistry { /** * Open a channel because the agent's words were delivered into it (routed * speech, or an explicit send tool), so typing indicators, reaction - * machinery and inbound forwarding come alive with the reply. Only a - * channel nobody decided to keep closed opens: one with no decision on - * record, a pure default, or a machine's close (subscription-gc, - * housekeeping). The agent's own close or decline stands, and so does a - * tune-out; a channel decided open whose transport is down is - * reconciliation's to reopen, so its decision keeps its source. A failed + * machinery and inbound forwarding come alive with the reply. A closed + * channel opens as it always did, unless the agent decided to keep it + * closed itself (channel_close, channel_decline): that decision stands + * against its own speech. A tune-out stands too, since it's the agent's + * decision as well, and its transport stays open. Where nobody decided + * (no record, a pure default) or a machine closed the channel + * (subscription-gc, housekeeping), the channel opens, and so does one + * decided open whose open failed, keeping that decision's source. A failed * open is traced and reported, and never stops the words: they're * delivered either way. */ @@ -2688,7 +2690,7 @@ export class ChannelRegistry { if (!entry) return 'unknown-channel'; if (entry.open) return 'not-opened'; const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); - if (current?.state === 'open' || current?.state === 'tuned-out') return 'not-opened'; + if (current?.state === 'tuned-out') return 'not-opened'; if (current?.state === 'closed' && AGENT_CLOSE_SOURCES.has(current.source)) return 'not-opened'; try { await this.openChannelNow(entry, source); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 881c654d4..b5d38ef65 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -610,20 +610,23 @@ test("openForSpeech opens a closed channel nobody decided to keep closed, and le await registry.handleChannelToolCall('channel_open', { channelId: 'open-now' }, agent); await registry.handleChannelToolCall('channel_open', { channelId: 'tuned-out' }, agent); registry.enterTuneOut('discord', 'tuned-out', { epochId: 'e1', cadenceSeconds: 600, backlogCap: 20, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); - // Decided open, with its transport down. await registry.handleChannelToolCall('channel_open', { channelId: 'decided-open' }, agent); - (registry as unknown as { findChannelEntry(id: string): { open: boolean } }).findChannelEntry('decided-open').open = false; + // The tune-out's transport and the decided-open channel's are down. + const entry = (id: string) => (registry as unknown as { findChannelEntry(id: string): { open: boolean } }).findChannelEntry(id); + entry('tuned-out').open = false; + entry('decided-open').open = false; const opensBefore = openCalls.length; assert.equal(await registry.openForSpeech('discord', 'undecided', 'opened-by-reply'), 'opened'); + assert.equal(await registry.openForSpeech('discord', 'decided-open', 'opened-by-reply'), 'opened', 'opened again, as on main'); assert.equal(await registry.openForSpeech('discord', 'agent-closed', 'opened-by-reply'), 'not-opened'); - assert.equal(await registry.openForSpeech('discord', 'decided-open', 'opened-by-reply'), 'not-opened'); assert.equal(await registry.openForSpeech('discord', 'tuned-out', 'opened-by-reply'), 'not-opened'); assert.equal(await registry.openForSpeech('discord', 'open-now', 'opened-by-reply'), 'not-opened'); assert.equal(await registry.openForSpeech('discord', 'nowhere', 'opened-by-reply'), 'unknown-channel'); - assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['undecided']); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['undecided', 'decided-open']); assert.equal(registry.getDesiredState('discord', 'undecided'), 'open'); + assert.equal(registry.getDesiredState('discord', 'decided-open'), 'open'); assert.equal(registry.getDesiredState('discord', 'tuned-out'), 'tuned-out', 'the tune-out stands'); assert.equal(registry.closedByAgent('discord', 'agent-closed'), 'closed'); }); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 65fbc7102..7fdc77577 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -366,21 +366,25 @@ test('a reply_message into a channel the agent closed leaves it closed, and reco 'recorded by the descriptor id the re-pin compares, before the tool result is queued'); }); -test('a reply_message into a channel decided open whose open failed retries no open', async () => { +test('a reply_message into a channel decided open whose open failed opens it again, as on main', async () => { const { registry, opens, windowMessages, reply, setRefuseOpens } = await harness(); // The agent opened it, but the server refused: decided open, transport closed. setRefuseOpens(true); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, false); assert.equal(registry.getDesiredState('discord', channelId), 'open'); assert.equal(registry.isChannelOpen(channelId), false); + setRefuseOpens(false); const opensBefore = opens.length; - const { success } = await reply(); + const { success, data } = await reply(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(success, true); - assert.equal(opens.length, opensBefore, 'the send attempts no channels/open'); - assert.equal(registry.isChannelOpen(channelId), false); - assert.deepEqual(windowMessages, []); + assert.ok(!data.includes('[closed channel]'), 'no note: the agent decided this channel open'); + assert.equal(opens.length, opensBefore + 1, 'the send opened it again'); + assert.equal(registry.isChannelOpen(channelId), true); + assert.equal(registry.getDesiredState('discord', channelId), 'open'); + assert.match(windowMessages.at(-1) ?? '', /^\[channels\] Now open: #upkeep \(discord:g1:444555\) — your own message into the closed channel engaged it\./); }); test('a reply_message into a tuned-out channel leaves the tune-out in place', async () => { From 4b229d3f75bdc55671e6fe4ffb6cb5cf1a0b0797 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:17:00 -0400 Subject: [PATCH 21/37] fix(channels): the invitation says what replying does; one predicate decides whether speech opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the narrow rule a reply opens a closed channel nobody decided to keep closed, so the closed-channel invitation's "1. Reply without joining … follow-ups to your reply will NOT reach you" was false where invitations mostly appear (the first mention in a channel nobody decided about), as it was on main. Ada-1017's call (room-203 #120594, on my #120583): option 1 states each case directly, since the registry knows which it is. - ChannelRegistry.speechOpens(serverId, channelId): whether the agent's words open the channel. Closed, the server's grant has channels.lifecycle, not tuned out, and not the agent's own close or decline. openForSpeech opens only where it says so; the invitation reads it. - On a server without channels.lifecycle, speech no longer calls openChannelNow, which recorded a desired 'open' before failing on the grant ("intent sticks", right for channel_open); speech there publishes and records nothing. - The invitation, where a reply joins: "1. Reply — write your reply this turn prefixed with ">>T"; it will be delivered there, and replying joins the channel, as option 2 does. To reply without joining, decline it first (option 3): a channel you've declined stays closed when you speak there." The no-target variant gets the same tail. Where a reply leaves the channel closed (the agent closed or declined it, or nothing can open), it reads as before. Tests: trunk-channel-routing's invitation stubs answer speechOpens; the first invitation test now expects the joining text, and a new test takes both variants of the kept-closed text, asked for the channel on its own server; the no-target assertion expects the joining tail. The routing tests pin speechOpens for each case, and that a lifecycle-less server records no intent. Fragments describe the invitation and speechOpens. --- changelog.d/agent-close-holds.breaking.md | 18 +++++-- changelog.d/agent-close-holds.fixed.md | 8 +-- src/framework.ts | 18 +++++-- src/mcpl/channel-registry.ts | 38 ++++++++------ test/channel-registry-routing.test.ts | 11 ++++- test/trunk-channel-routing.test.ts | 60 +++++++++++++++++++++-- 6 files changed, 122 insertions(+), 31 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index ff3032e7d..0c45b00e4 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -21,6 +21,14 @@ conversation agent gets none: `channel_open` is refused to it, and its settings end with its conversation. In such a channel, the `sleep` tool's default announcement says "I'll still see messages addressed to me, but won't respond until I wake." + - **What the invitation says:** the closed-channel invitation's first option now says what + replying does there. In a channel the agent closed or declined itself, it reads as before: + "Reply without joining …", with the note that follow-ups that don't address the agent won't + reach it. In any other closed channel, where a reply opens the channel, it reads "1. Reply — + … it will be delivered there, and replying joins the channel, as option 2 does. To reply + without joining, decline it first (option 3): a channel you've declined stays closed when you + speak there." Before, it offered "Reply without joining" there too, though the reply opened + the channel. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the @@ -32,10 +40,12 @@ `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went - to, from a composite or raw id, and opens nothing. `openForSpeech(serverId, channelId, source)` - opens it as the framework's send path and `routeSpeech` do: a closed channel opens unless the - agent closed or declined it, or it's tuned out. - `closedByAgent(serverId, channelId)` and `canOpenChannels(serverId)` are new. + to, from a composite or raw id, and opens nothing. `speechOpens(serverId, channelId)` says whether + the agent's words there open the channel: a closed channel opens unless the agent closed or + declined it, it's tuned out, or its server's grant lacks `channels.lifecycle`. + `openForSpeech(serverId, channelId, source)` opens it where `speechOpens` says so, as the + framework's send path and `routeSpeech` do. `closedByAgent(serverId, channelId)` and + `canOpenChannels(serverId)` are new. - **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so the first request after the upgrade that carries those tools misses the cache from its tool block on. diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index d5d26fd36..f4b53df0f 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -1,7 +1,9 @@ - A failed open no longer stops the agent's speech. Speech into a closed channel whose open fails, - on a server whose grant lacks `channels.lifecycle` or on a dead transport, is still published. - Before, the open that preceded publishing threw, and the speech failed with "locus channel is - closed and open failed". A send tool's call was already published before its open. + as on a dead transport, is still published. Before, the open that preceded publishing threw, and + the speech failed with "locus channel is closed and open failed". A send tool's call was already + published before its open. On a server whose grant lacks `channels.lifecycle`, where no channel + can open, speech now publishes without trying to open the channel, and records no intent to + open it. Before, it recorded that intent, and then failed. - A publish that throws, as on a dead transport, is now a failed route. The agent gets the `[discord-send-failed]` marker, and `mcpl:speech-route-failed` is traced. Before, the framework only logged it. diff --git a/src/framework.ts b/src/framework.ts index 564ac2343..56ca2fc66 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9921,11 +9921,21 @@ export class AgentFramework { const target = typeof namer.proseTargetFor === 'function' ? namer.proseTargetFor(opts.channelId, opts.serverId) : [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t)); + // What replying does here is the registry's to say (speechOpens): a reply + // joins a channel nobody decided to keep closed, and leaves one the agent + // closed or declined itself closed. Each case is said as it is. + const replyJoins = this.channelRegistry.speechOpens(opts.serverId, opts.channelId); + const withoutJoining = + "To reply without joining, decline it first (option 3): a channel you've declined stays closed when you speak there."; const replyOption = target - ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + - `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` - : `1. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; ` + - `check whether ${opts.serverId} exposes a send tool that can address it.\n`; + ? replyJoins + ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, and replying ` + + `joins the channel, as option 2 does. ${withoutJoining}\n` + : `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` + : `1. Reply${replyJoins ? '' : ' without joining'} — no single-word target reaches this channel unambiguously, ` + + `so a ">>" prefix can't name it; check whether ${opts.serverId} exposes a send tool that can address it.` + + (replyJoins ? ` Replying there joins the channel, as option 2 does. ${withoutJoining}\n` : '\n'); const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index e5db893a2..720f9a68d 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2667,19 +2667,32 @@ export class ChannelRegistry { return !!server && CapabilityGrant.of(server).has('channels.lifecycle'); } + /** + * Whether the agent's words delivered into this channel open it + * (openForSpeech). A closed channel opens as it always did, unless the + * agent decided to keep it closed itself (channel_close, channel_decline): + * that decision stands against its own speech. A tune-out stands too, + * since it's the agent's decision as well. Where nobody decided (no + * record, a pure default) or a machine closed the channel + * (subscription-gc, housekeeping), the channel opens, and so does one + * decided open whose open failed. Nothing opens on a server whose grant + * lacks channels.lifecycle, so speech there records no intent to open. + */ + speechOpens(serverId: string, channelId: string): boolean { + const entry = this.channels.get(`${serverId}:${channelId}`); + if (!entry || entry.open || !this.canOpenChannels(serverId)) return false; + const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); + if (current?.state === 'tuned-out') return false; + return !(current?.state === 'closed' && AGENT_CLOSE_SOURCES.has(current.source)); + } + /** * Open a channel because the agent's words were delivered into it (routed * speech, or an explicit send tool), so typing indicators, reaction - * machinery and inbound forwarding come alive with the reply. A closed - * channel opens as it always did, unless the agent decided to keep it - * closed itself (channel_close, channel_decline): that decision stands - * against its own speech. A tune-out stands too, since it's the agent's - * decision as well, and its transport stays open. Where nobody decided - * (no record, a pure default) or a machine closed the channel - * (subscription-gc, housekeeping), the channel opens, and so does one - * decided open whose open failed, keeping that decision's source. A failed - * open is traced and reported, and never stops the words: they're - * delivered either way. + * machinery and inbound forwarding come alive with the reply, where + * speechOpens says speech opens it. A channel decided open keeps that + * decision's source. A failed open is traced and reported, and never stops + * the words: they're delivered either way. */ async openForSpeech( serverId: string, @@ -2688,10 +2701,7 @@ export class ChannelRegistry { ): Promise<'opened' | 'not-opened' | 'open-failed' | 'unknown-channel'> { const entry = this.channels.get(`${serverId}:${channelId}`); if (!entry) return 'unknown-channel'; - if (entry.open) return 'not-opened'; - const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); - if (current?.state === 'tuned-out') return 'not-opened'; - if (current?.state === 'closed' && AGENT_CLOSE_SOURCES.has(current.source)) return 'not-opened'; + if (!this.speechOpens(serverId, channelId)) return 'not-opened'; try { await this.openChannelNow(entry, source); this.emitTraceFn({ type: 'mcpl:channel-opened-by-send', serverId, channelId }); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index b5d38ef65..dda1c7adb 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -359,7 +359,7 @@ test('routeSpeech still delivers when the open on delivery fails', async () => { assert.equal(lookup('dm-alice')!.open, false); }); -test('routeSpeech publishes on a server whose grant lacks channels.lifecycle, where nothing can open', async () => { +test('routeSpeech publishes on a server whose grant lacks channels.lifecycle, where nothing opens', async () => { const published: Array = []; const server = { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), []), @@ -378,9 +378,11 @@ test('routeSpeech publishes on a server whose grant lacks channels.lifecycle, wh const res = await registry.routeSpeech('sol', 'hello from outside', 'lobby'); - assert.deepEqual(res, { delivered: true, channelId: 'lobby' }, 'the open fails, and the words still go'); + assert.deepEqual(res, { delivered: true, channelId: 'lobby' }, 'nothing opens, and the words still go'); assert.deepEqual(published, ['lobby']); assert.equal(registry.canOpenChannels('portal'), false); + assert.equal(registry.speechOpens('portal', 'lobby'), false); + assert.equal(registry.getDesiredState('portal', 'lobby'), undefined, 'speech records no intent to open'); }); test('routeSpeech reports a publish that throws as a failed route (R1-4)', async () => { @@ -617,6 +619,11 @@ test("openForSpeech opens a closed channel nobody decided to keep closed, and le entry('decided-open').open = false; const opensBefore = openCalls.length; + assert.deepEqual(ids.map((id) => [id, registry.speechOpens('discord', id)]), [ + ['undecided', true], ['agent-closed', false], ['decided-open', true], ['tuned-out', false], ['open-now', false], + ]); + assert.equal(registry.speechOpens('discord', 'nowhere'), false); + assert.equal(await registry.openForSpeech('discord', 'undecided', 'opened-by-reply'), 'opened'); assert.equal(await registry.openForSpeech('discord', 'decided-open', 'opened-by-reply'), 'opened', 'opened again, as on main'); assert.equal(await registry.openForSpeech('discord', 'agent-closed', 'opened-by-reply'), 'not-opened'); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 6aa8f1e59..e3c56b64f 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -158,6 +158,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -195,20 +196,65 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.match(text, /optionally set acknowledge/); assert.match(text, /Doing nothing is also fine\./); // 2026-08-05 accuracy pass: the delivery model must be stated correctly — - // direct addresses still arrive; un-@'d follow-ups to a reply do not. + // direct addresses still arrive. assert.match(text, /address you directly/); - assert.match(text, /follow-ups to your reply will NOT reach you/); + // Here nobody decided to keep the channel closed, so a reply joins it, and + // the invitation says so, with how to reply without joining. + assert.match(text, /\n1\. Reply — write your reply this turn prefixed with ">>#portables"; it will be delivered there, and replying joins the channel, as option 2 does\. To reply without joining, decline it first \(option 3\): a channel you've declined stays closed when you speak there\.\n/); + assert.doesNotMatch(text, /Reply without joining|follow-ups to your reply will NOT reach you/); // No surface-tracked tally on this event → no missed-count sentence. assert.doesNotMatch(text, /passed there without you/); await framework.stop(); }); + it('in a channel the agent closed or declined itself, the invitation offers a reply without joining', async () => { + const textOf = (framework: Awaited>) => + framework.getAgent('scout')!.getContextManager().queryMessages({}).messages.at(-1)?.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('\n') ?? ''; + const mention = (id: string) => ({ + type: 'mcpl:push-event' as const, + serverId: 'discord', + featureSet: 'discord.messaging', + eventId: `discord_msg_${id}`, + content: [{ type: 'text' as const, text: 'Antra: can you look at this?' }], + origin: { source: 'discord', messageId: id, mcplChannelId: 'discord:G1:C7', channelName: 'portables' }, + tags: ['chat:mention', 'chat:addressed'], + timestamp: new Date().toISOString(), + inferenceId: `i-${id}`, + triggerInference: false, + }); + for (const target of ['#portables', undefined]) { + const framework = await makeFramework(); + const asked: string[] = []; + internals(framework).channelRegistry = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + // A reply leaves this channel closed: the agent closed or declined it. + speechOpens: (serverId: string, channelId: string) => { asked.push(`${serverId}/${channelId}`); return false; }, + getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), + proseTargetFor: () => target, + stopAll: () => {}, + }; + internals(framework).handleMcplPushEvent(mention(`m-${target ?? 'none'}`)); + const text = textOf(framework); + assert.deepEqual(asked, ['discord/discord:G1:C7'], 'asked about this channel on the server it came from'); + assert.match(text, target + ? /\n1\. Reply without joining — write your reply this turn prefixed with ">>#portables"; it will be delivered there\. Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message\.\n/ + : /\n1\. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; check whether discord exposes a send tool that can address it\.\n/); + assert.doesNotMatch(text, /joins the channel|decline it first/); + await framework.stop(); + } + }); + it('surfaces the missed-ambient tally in the closed-channel invitation', async () => { const framework = await makeFramework(); const i = internals(framework); i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -274,6 +320,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: (id: string, serverId?: string) => { asked.push(`${serverId}/${id}`); return '@_reim0n'; }, stopAll: () => {}, @@ -292,6 +339,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, stopAll: () => {}, @@ -300,8 +348,9 @@ describe('Trunk channel routing (item-3 redux)', () => { const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); assert.match(t2, /no single-word target reaches this channel unambiguously/); - assert.match(t2, /check whether discord exposes a send tool that can address it\.\n/, - 'replying needs no join, and the hint promises no tool'); + assert.match(t2, /\n1\. Reply — no single-word target reaches this channel unambiguously/); + assert.match(t2, /check whether discord exposes a send tool that can address it\. Replying there joins the channel, as option 2 does\. To reply without joining, decline it first \(option 3\): a channel you've declined stays closed when you speak there\.\n/, + 'the hint promises no tool, and says what replying does'); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'joining stays available'); await fw2.stop(); @@ -311,6 +360,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i3.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => undefined, stopAll: () => {}, }; @@ -328,6 +378,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => '@_reim0n', stopAll: () => {}, @@ -360,6 +411,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, getDesiredState: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => '#portables', From e23921e71719cc4874521a474a6dc168f755a861 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:21:02 -0400 Subject: [PATCH 22/37] test(channels): speech's reopen of a channel decided open keeps the agent's open, which a machine's close can't override openForSpeech's test now closes the reopened 'decided-open' channel with a subscription-gc close and expects the 'explicit-open' refusal: the reopen goes through setDesiredState at the same state, which keeps the agent-tool source on record. --- test/channel-registry-routing.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index dda1c7adb..4586d000a 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -636,6 +636,12 @@ test("openForSpeech opens a closed channel nobody decided to keep closed, and le assert.equal(registry.getDesiredState('discord', 'decided-open'), 'open'); assert.equal(registry.getDesiredState('discord', 'tuned-out'), 'tuned-out', 'the tune-out stands'); assert.equal(registry.closedByAgent('discord', 'agent-closed'), 'closed'); + // Opened again by speech, the agent's own open keeps its source, so a machine's close still can't override it. + const gc = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'decided-open', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + assert.equal(gc.success, false); + assert.equal((gc.data as { refusal?: string } | undefined)?.refusal, 'explicit-open'); + assert.equal(registry.isChannelOpen('decided-open'), true); }); test('policy admissions are announced to the agent exactly once, as one batch', async () => { From 6a0f4cdac5192c62779e7c454a5377699b5bbacb Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:28:16 -0400 Subject: [PATCH 23/37] test(channels): each open by speech is traced (mcpl:channel-opened-by-send) openForSpeech's test now expects the trace for each channel it opened, as main's routeSpeech and openIfClosedForSend emitted it. Removing the trace (open-untraced in the removal run at 4b229d3f) failed no test before. --- test/channel-registry-routing.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 4586d000a..952721841 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -602,7 +602,7 @@ test('resolveSendTarget names the channel a send went to and guesses nothing', a }); test("openForSpeech opens a closed channel nobody decided to keep closed, and leaves every other one as it is", async () => { - const { registry, openCalls } = makeRegistry({ delivered: true }); + const { registry, openCalls, traces } = makeRegistry({ delivered: true }); const ids = ['undecided', 'agent-closed', 'decided-open', 'tuned-out', 'open-now']; await registry.handleChanged('discord', { added: ids.map((id) => ({ id, type: 'discord', label: `#${id}`, direction: 'bidirectional' })), @@ -632,6 +632,8 @@ test("openForSpeech opens a closed channel nobody decided to keep closed, and le assert.equal(await registry.openForSpeech('discord', 'nowhere', 'opened-by-reply'), 'unknown-channel'); assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['undecided', 'decided-open']); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:channel-opened-by-send').map((t) => t.channelId), + ['undecided', 'decided-open'], 'each open by speech is traced'); assert.equal(registry.getDesiredState('discord', 'undecided'), 'open'); assert.equal(registry.getDesiredState('discord', 'decided-open'), 'open'); assert.equal(registry.getDesiredState('discord', 'tuned-out'), 'tuned-out', 'the tune-out stands'); From 377c84b1f924959cb595e7343e7a2a7eb06d528c Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 00:32:30 -0400 Subject: [PATCH 24/37] docs(changelog): the fragments name zulip_mcp's topics and where a heartbeat speaks - breaking: on zulip_mcp, in a stream the agent closed, every answer now goes to the topic from before the close (R1-2; probed at zulip_mcp 0642e33, af330-zulip-probe-0642e33.txt); and a turn with no trigger doesn't come to speak in a channel the agent closed, since only channels/incoming moves where it speaks (Ada-1017 accepted this, room-203 #120742). - fixed: the agent's close over a default or a machine's close holds against its own speech as well as policy and initiallyOpen. --- changelog.d/agent-close-holds.breaking.md | 9 +++++++-- changelog.d/agent-close-holds.fixed.md | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 0c45b00e4..1c213240e 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -33,11 +33,16 @@ forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the agent opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: - discord-mcpl requests no DM reaction events. + discord-mcpl requests no DM reaction events. On zulip_mcp, an answer's topic comes from the + last message delivered as `channels/incoming`, and a closed stream's mentions are pushed + instead, so in a stream the agent closed, every answer goes to the topic of the stream's last + message from before the close, where a reply used to reopen the stream. - **What hosts notice:** on discord-mcpl, a channel the agent closed keeps delivering the messages that address the agent as push events, as any closed channel's are, where a reply used to reopen it and switch them to `channels/incoming`. Host code that acts on - `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. + `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. Nor does + a turn with no trigger, such as a heartbeat, come to speak there: where it speaks moves only + with `channels/incoming` messages. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went to, from a composite or raw id, and opens nothing. `speechOpens(serverId, channelId)` says whether diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index f4b53df0f..fd9e00577 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -10,9 +10,10 @@ - A send tool's call into a tuned-out channel no longer ends the tune-out. It used to set the channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". - The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which - is closed by default, or of one a machine closed, is now recorded as its decision. Before, the - channel kept its default or the machine's source, so subscription policy or a server's - `initiallyOpen` could open the first later. A machine close of a channel nobody decided about, + is closed by default, or of one a machine closed, is now recorded as its decision, so neither + subscription policy, a server's `initiallyOpen` nor the agent's own speech there opens it later. + Before, the channel kept its default, which policy and `initiallyOpen` lift, or the machine's + source. A machine close of a channel nobody decided about, such as connectome-host's `subscription-gc`, still records no decision: the channel keeps its default, so policy can still open it. That now holds for a channel with no record at all, one registered lazily or on a server whose grant lacks `channels.lifecycle`, where a machine close From 291544bf752bd85a7ba7fe3b417415684b6ae8e9 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 01:08:58 -0400 Subject: [PATCH 25/37] docs(changelog): name connectome-host's mentions mode and discord-mcpl's unmute-on-open From Greta-2425's design read (room-203 #121470): - connectome-host's mentions mode closes with channel_close (channel-mode-module.ts:314 at 38b55da6), so a resident in it gets the note with every reply to a mention there; the mode's own tool is the way back, which agent-framework can't name. - discord-mcpl's channels/open lifts a mute (subscribeRawChannel, src/server.ts:3553-3554 at 568c293; run: af330-dmcpl-open-unmutes-568c293.txt), so a reply that opens a muted channel nobody decided about, or one a machine closed, unmutes it as on main; a channel the agent closed keeps its mute. --- changelog.d/agent-close-holds.breaking.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 1c213240e..1086c9002 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -20,7 +20,10 @@ default. A `send_dm`'s answer never carries it, since the tool sends to a DM, and a conversation agent gets none: `channel_open` is refused to it, and its settings end with its conversation. In such a channel, the `sleep` tool's default announcement says "I'll still see - messages addressed to me, but won't respond until I wake." + messages addressed to me, but won't respond until I wake." In connectome-host's `mentions` + channel mode, which closes a channel with `channel_close`, the note comes with every reply to + a mention there until the resident turns it off; the mode's own tool, not `channel_open`, is + the way back to its other mode. - **What the invitation says:** the closed-channel invitation's first option now says what replying does there. In a channel the agent closed or declined itself, it reads as before: "Reply without joining …", with the note that follow-ups that don't address the agent won't @@ -33,7 +36,9 @@ forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the agent opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: - discord-mcpl requests no DM reaction events. On zulip_mcp, an answer's topic comes from the + discord-mcpl requests no DM reaction events. discord-mcpl's `channels/open` also lifts a mute + set with `mute_channel`, so a reply that opens a muted channel nobody decided about, or one a + machine closed, unmutes it, as before; a channel the agent closed keeps its mute. On zulip_mcp, an answer's topic comes from the last message delivered as `channels/incoming`, and a closed stream's mentions are pushed instead, so in a stream the agent closed, every answer goes to the topic of the stream's last message from before the close, where a reply used to reopen the stream. From f81cd4cb63fda30af72a1cbb7e69d32c29be91e1 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 01:16:29 -0400 Subject: [PATCH 26/37] fix(channels): a send into a channel the agent tuned out says the tune-out stands (Ada-1017, room-203 #121570, #121673) On main a send tool's call ended a tune-out (openIfClosedForSend set the decision to open); with this change it stands, and nothing told the agent, which then stops hearing the answers it sent there expecting. The send tool's answer now ends with one line, tagged [tuned out]: '#x () stays tuned out: your words there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out ends. tune_out with channelId "" and mode "cancel" resumes normal attention there.' plus the stop sentence. 'Resumes normal attention' is channel_open's own refusal wording for a tuned-out channel. - Only the send tools: routed speech never ended a tune-out while its transport was open. send_dm included: a tune-out diverts a DM too, and main's send_dm ended one. - The closed_channel_note setting turns it off, a conversation agent gets none, and a failure while working it out is logged and adds nothing. - channelNotePlace() is the label-and-id both notes use; the setting's description names the tune-out. Test: send-into-closed-channel's tune-out test, renamed, now expects the line on reply_message and send_dm, none with the setting off or for a conversation agent, and still that the tune-out stands with no open. --- src/framework.ts | 50 ++++++++++++++++++++++----- test/send-into-closed-channel.test.ts | 26 ++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 56ca2fc66..cb0ea73c5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4163,8 +4163,9 @@ export class AgentFramework { properties: { closed_channel_note: { type: 'boolean', - description: 'Add a note when your words go to a channel you closed or declined, saying that it stays closed, ' + - 'what still reaches you from it, and how to open it. On by default. Persists across turns and restarts until changed.', + description: 'Add a note when your words go to a channel you closed or declined, or a send tool\'s words to one ' + + 'you tuned out, saying that it stays that way and how to change that. On by default. ' + + 'Persists across turns and restarts until changed.', }, }, keys: ['closed_channel_note'], @@ -4195,10 +4196,7 @@ export class AgentFramework { if (!registry || !serverId || registry.isChannelOpen(channelId)) return undefined; const how = registry.closedByAgent(serverId, channelId); if (!how) return undefined; - const label = registry.getDescriptor(channelId)?.label; - const place = label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId; + const place = this.channelNotePlace(channelId); const reach = 'Messages there that address you, such as an @-mention, still reach you'; const open = registry.canOpenChannels(serverId) ? `; channel_open with channelId "${channelId}" and serverId "${serverId}" opens it.` @@ -4206,6 +4204,38 @@ export class AgentFramework { return `${place} stays closed: you ${how} it, and your words there don't open it. ${reach}${open}`; } + /** A channel as a note names it: its label and id, or its id alone. */ + private channelNotePlace(channelId: string): string { + const label = this.channelRegistry?.getDescriptor(channelId)?.label; + return label && label !== channelId + ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` + : channelId; + } + + /** What an agent is told when a send tool's words go to a channel it tuned + * out, or undefined where nothing needs saying. On main a send ended the + * tune-out; now it stands, so the agent that sent there expecting answers + * learns they still go to its subconscious. Only the send tools ended a + * tune-out (routed speech never did while the transport was open), so only + * their answers carry this, send_dm's included: a tune-out diverts a DM + * too. The same setting turns it off, a conversation agent gets none, and + * a failure while working it out is logged and adds nothing, as for the + * closed-channel note. */ + private tunedOutSendNote(agentName: string, channelId: string, serverId: string): string | undefined { + try { + const registry = this.channelRegistry; + if (!registry || this.conversationAgentHomes.has(agentName)) return undefined; + if (registry.getDesiredState(serverId, channelId) !== 'tuned-out') return undefined; + if (!this.closedChannelNoteOn(agentName)) return undefined; + return `${this.channelNotePlace(channelId)} stays tuned out: your words there don't end the tune-out, so its ` + + 'traffic still goes to your subconscious until the tune-out ends. ' + + `tune_out with channelId "${channelId}" and mode "cancel" resumes normal attention there.`; + } catch (err) { + console.error(`[closed-channel-note] for ${agentName} failed: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + } + /** How to stop the closed-channel note, said once wherever it appears. */ private static readonly CLOSED_CHANNEL_NOTE_OFF = 'To stop this note, set closed_channel_note to false with agent_settings.'; @@ -18399,12 +18429,14 @@ export class AgentFramework { if (engagedChannel) { sendingTurn?.engaged.add(engagedChannel); // Where the agent's words stayed in a channel it closed or - // declined itself, the send's answer says so. - const [note] = toolName === 'send_dm' ? [] : this.closedChannelNotes(agentName, [engagedChannel], serverId); + // declined itself, or tuned out, the send's answer says so. + const [closedNote] = toolName === 'send_dm' ? [] : this.closedChannelNotes(agentName, [engagedChannel], serverId); + const tunedOutNote = this.tunedOutSendNote(agentName, engagedChannel, serverId); + const note = closedNote ? `[closed channel] ${closedNote}` : tunedOutNote ? `[tuned out] ${tunedOutNote}` : undefined; if (note) { result.content = [ ...(result.content ?? []), - { type: 'text', text: `[closed channel] ${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, + { type: 'text', text: `${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, ]; } // Fire-and-forget: the send already succeeded; the open must not diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 7fdc77577..593070552 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -387,21 +387,35 @@ test('a reply_message into a channel decided open whose open failed opens it aga assert.match(windowMessages.at(-1) ?? '', /^\[channels\] Now open: #upkeep \(discord:g1:444555\) — your own message into the closed channel engaged it\./); }); -test('a reply_message into a tuned-out channel leaves the tune-out in place', async () => { - const { registry, opens, windowMessages, reply } = await harness(); +test('a send into a tuned-out channel leaves the tune-out in place, and its answer says so', async () => { + const { registry, framework, opens, windowMessages, reply } = await harness(); // Tuned out: the transport stays open, and the decision is the tune-out. assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); registry.enterTuneOut('discord', channelId, { epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, }, 'agent-tool'); const opensBefore = opens.length; + const tunedOutNote = `[tuned out] #upkeep (${channelId}) stays tuned out: your words there don't end the tune-out, ` + + 'so its traffic still goes to your subconscious until the tune-out ends. ' + + `tune_out with channelId "${channelId}" and mode "cancel" resumes normal attention there. ` + + 'To stop this note, set closed_channel_note to false with agent_settings.'; - const { success } = await reply(); - - assert.equal(success, true); + for (const tool of ['reply_message', 'send_dm']) { + const { success, data } = await reply(tool); + assert.equal(success, true); + assert.ok(data.endsWith(`\n${tunedOutNote}`), `${tool}: ${data}`); + } assert.equal(registry.getDesiredState('discord', channelId), 'tuned-out', 'the tune-out stands'); assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); assert.deepEqual(windowMessages, [], 'no notice says the channel opened'); + + // The setting turns it off too. + framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); + assert.ok(!(await reply()).data.includes('[tuned out]')); + framework.closedChannelNoteSettingsExtension().reset('sol'); + // A conversation agent gets none. + framework.conversationAgentHomes.set('sol', channelId); + assert.ok(!(await reply()).data.includes('[tuned out]')); }); test('a reply_message still in flight when the agent closes its channel leaves it closed', async () => { @@ -451,7 +465,7 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat const tool = framework.getAllTools().find((t) => t.name === 'agent_settings')!; const property = (tool.inputSchema.properties as Record).closed_channel_note; assert.equal(property.type, 'boolean'); - assert.match(property.description, /a channel you closed or declined/); + assert.match(property.description, /a channel you closed or declined, or a send tool's words to one you tuned out/); assert.equal(settings(framework, { action: 'get' }).data?.closed_channel_note, true, 'on by default'); // Another framework-owned setting, kept in the same state. From 762988f450edfed9aeee9c56a3d68a2d5074d111 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 01:18:07 -0400 Subject: [PATCH 27/37] fix(channels): a module's close is the module's: speech leaves it closed, with no note (Ada-1017, room-203 #121570, #121673) connectome-host's mentions mode closes a channel through its module's own tool call (ctx.callTool, channel-mode-module.ts:314 at 38b55da6), which was recorded as 'agent-tool'. So a resident in that mode got the closed-channel note with every reply to a mention, advising channel_open, which would leave the mode half-changed: the mode's own tool is the way back (Greta-2425, #121470). A module-origin close without a machine source is now recorded as MODULE_CLOSE_SOURCE, 'module-tool': - speechOpens refuses it, as it does the agent's own close; closedByAgent doesn't name it, so no note, and the invitation there gives "Reply without joining", which is true. - setDesiredState: a module's close replaces a pure default or a machine's close at the same state; the agent's own close or decline also replaces a module's; a machine's never replaces one. - handleToolClose: the refusal of a machine's close of an explicit open now tests MACHINE_CLOSE_SOURCES rather than "not agent-tool", so a module's close of a channel its other mode opened isn't refused. - Opens a module makes stay 'agent-tool' (GC's refusal protects them). Tests: channel-registry-routing's new test takes a module's close of a default and of a module-made open: not refused, speech leaves it closed, not the agent's, policy doesn't lift it, a machine's close doesn't replace it, the agent's own close does. send-into-closed-channel: a send into a channel a module closed carries no note and opens nothing. The fragment names the new source and the tune-out line. --- changelog.d/agent-close-holds.breaking.md | 20 +++++++--- src/mcpl/channel-registry.ts | 48 +++++++++++++++-------- test/channel-registry-routing.test.ts | 39 ++++++++++++++++++ test/send-into-closed-channel.test.ts | 14 ++++++- 4 files changed, 98 insertions(+), 23 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 1086c9002..79db2c0c9 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -20,10 +20,11 @@ default. A `send_dm`'s answer never carries it, since the tool sends to a DM, and a conversation agent gets none: `channel_open` is refused to it, and its settings end with its conversation. In such a channel, the `sleep` tool's default announcement says "I'll still see - messages addressed to me, but won't respond until I wake." In connectome-host's `mentions` - channel mode, which closes a channel with `channel_close`, the note comes with every reply to - a mention there until the resident turns it off; the mode's own tool, not `channel_open`, is - the way back to its other mode. + messages addressed to me, but won't respond until I wake." A send tool's call into a channel + the agent tuned out, which ended the tune-out before, now leaves it in place, and its answer + says so: "… stays tuned out: your words there don't end the tune-out, so its traffic still + goes to your subconscious until the tune-out ends. tune_out with channelId … and mode "cancel" + resumes normal attention there." The same setting turns that line off. - **What the invitation says:** the closed-channel invitation's first option now says what replying does there. In a channel the agent closed or declined itself, it reads as before: "Reply without joining …", with the note that follow-ups that don't address the agent won't @@ -48,11 +49,20 @@ `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. Nor does a turn with no trigger, such as a heartbeat, come to speak there: where it speaks moves only with `channels/incoming` messages. +- **Hosts with modules that close channels:** a module's `channel_close` through its own tool + call (`ctx.callTool`) without a machine source is now recorded with the source `module-tool`, + where it was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, + but gives no note, since the module owns the way back. In connectome-host that's the `mentions` + channel mode: a reply to a mention in a channel in that mode stays a reply, and the invitation + there offers "Reply without joining". Policy doesn't lift such a close, a machine's close + doesn't replace it, and the agent's own close does. A module's close is never refused as a + machine's close of an explicit open; only one naming a machine source is. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went to, from a composite or raw id, and opens nothing. `speechOpens(serverId, channelId)` says whether the agent's words there open the channel: a closed channel opens unless the agent closed or - declined it, it's tuned out, or its server's grant lacks `channels.lifecycle`. + declined it, a module closed it, it's tuned out, or its server's grant lacks + `channels.lifecycle`. `openForSpeech(serverId, channelId, source)` opens it where `speechOpens` says so, as the framework's send path and `routeSpeech` do. `closedByAgent(serverId, channelId)` and `canOpenChannels(serverId)` are new. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 720f9a68d..a22d110f5 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -568,10 +568,17 @@ export type ChannelToolOrigin = export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']); /** The agent's own decisions to keep a channel closed: its channel_close and - * its decline of an invitation. Speech opens a closed channel only where - * nobody decided, or a machine did (openForSpeech); it never undoes these. */ + * its decline of an invitation. Speech never undoes these (speechOpens), and + * where the agent's words stay in such a channel it's told so. */ export const AGENT_CLOSE_SOURCES: ReadonlySet = new Set(['agent-tool', 'invitation-declined']); +/** A close a module made through its own tool call (ctx.callTool) without a + * machine source, such as connectome-host's mentions mode: a decision the + * module owns, with its own way back. Speech leaves it closed too, but the + * agent isn't told how to open it, since channel_open alone would undo only + * part of what the module did. */ +export const MODULE_CLOSE_SOURCE = 'module-tool'; + export class ChannelRegistry { private serverRegistry: McplServerRegistry; private featureSetManager: FeatureSetManager; @@ -1959,14 +1966,18 @@ export class ChannelRegistry { // bootstrap can still lift it (#417). let recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source; if (current?.state === desired) { - // The same state keeps the decision on record, except that the agent's - // own close or decline replaces a pure default or a machine's close, so - // it sticks: policy and bootstrap lift only 'default-closed', and speech - // opens only what the agent didn't close itself (openForSpeech). A - // machine source never replaces one, so what nobody decided stays - // liftable. - const replaceable = current.source === 'default-closed' || MACHINE_CLOSE_SOURCES.has(current.source); - recorded = replaceable && AGENT_CLOSE_SOURCES.has(source) ? source : current.source; + // The same state keeps the decision on record, except that a close + // someone decided replaces one nobody did, so it sticks: policy and + // bootstrap lift only 'default-closed', and speech opens only what + // nobody decided to keep closed (speechOpens). The agent's own close or + // decline replaces a pure default, a machine's close or a module's; a + // module's close replaces a pure default or a machine's. A machine + // source never replaces one, so what nobody decided stays liftable. + const undecided = current.source === 'default-closed' || MACHINE_CLOSE_SOURCES.has(current.source); + const replaces = AGENT_CLOSE_SOURCES.has(source) + ? undecided || current.source === MODULE_CLOSE_SOURCE + : source === MODULE_CLOSE_SOURCE && undecided; + recorded = replaces ? source : current.source; if (recorded === current.source) return; } this.desiredStates.set(key, { state: desired, source: recorded }); @@ -2671,8 +2682,9 @@ export class ChannelRegistry { * Whether the agent's words delivered into this channel open it * (openForSpeech). A closed channel opens as it always did, unless the * agent decided to keep it closed itself (channel_close, channel_decline): - * that decision stands against its own speech. A tune-out stands too, - * since it's the agent's decision as well. Where nobody decided (no + * that decision stands against its own speech. So does a module's close + * (MODULE_CLOSE_SOURCE), and a tune-out, the agent's decision as well. + * Where nobody decided (no * record, a pure default) or a machine closed the channel * (subscription-gc, housekeeping), the channel opens, and so does one * decided open whose open failed. Nothing opens on a server whose grant @@ -2683,7 +2695,7 @@ export class ChannelRegistry { if (!entry || entry.open || !this.canOpenChannels(serverId)) return false; const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); if (current?.state === 'tuned-out') return false; - return !(current?.state === 'closed' && AGENT_CLOSE_SOURCES.has(current.source)); + return !(current?.state === 'closed' && (AGENT_CLOSE_SOURCES.has(current.source) || current.source === MODULE_CLOSE_SOURCE)); } /** @@ -2792,7 +2804,8 @@ export class ChannelRegistry { * Honored ONLY for module-origin dispatch — tool input is untrusted, * so a model-origin call carrying this field is still recorded as * 'agent-tool' (the resident may close their own channel; they may - * not attribute the act to housekeeping). */ + * not attribute the act to housekeeping). A module-origin call + * without it is the module's own close, MODULE_CLOSE_SOURCE. */ source?: string; /** A machine close aimed at an explicitly-opened channel is refused * unless the caller certifies an explicit idle lease (a configured @@ -2814,11 +2827,12 @@ export class ChannelRegistry { // Provenance comes from trusted dispatch, not from self-description: // only module-origin calls may record a machine source, and only from - // the closed vocabulary. Agent-origin (or origin-less — the safe + // the closed vocabulary; without one, a module's close is its own + // (MODULE_CLOSE_SOURCE). Agent-origin (or origin-less — the safe // default for any legacy caller) records 'agent-tool' and has its // machine fields ignored entirely. const isModuleOrigin = origin?.kind === 'module'; - let closeSource = 'agent-tool'; + let closeSource = isModuleOrigin ? MODULE_CLOSE_SOURCE : 'agent-tool'; if (isModuleOrigin && input.source !== undefined) { if (!MACHINE_CLOSE_SOURCES.has(input.source)) { return { @@ -2850,7 +2864,7 @@ export class ChannelRegistry { // close of a channel whose current desired state is an agent/operator // 'open' is refused — structurally, so the caller can stand down rather // than retry — unless it certifies an explicit idle lease. - if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) { + if (isModuleOrigin && MACHINE_CLOSE_SOURCES.has(closeSource) && !input.overrideExplicitOpen) { const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId)); // Tuned-out is stated intent too: a GC/housekeeping close would // silently end the epoch and orphan its backlog. diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 952721841..2382fba98 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -583,6 +583,45 @@ test("channel_close's description says the close stands against speech and polic /It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it\.$/); }); +test("a module's close, made with no machine source, is the module's: speech leaves it closed, and it isn't the agent's", async () => { + const { registry, openCalls, publishCalls } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { + added: [ + { id: 'mode-a', type: 'discord', label: '#a', direction: 'bidirectional' }, + { id: 'mode-b', type: 'discord', label: '#b', direction: 'bidirectional' }, + ], + } as never); + // A module's tool call (ctx.callTool), as connectome-host's channel modes make them. + const mode = { kind: 'module' as const }; + const agent = { kind: 'agent' as const, agentName: 'sol' }; + // mode-b was opened by the module's other mode, which records an open as the agent's. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'mode-b' }, mode)).success, true); + for (const id of ['mode-a', 'mode-b']) { + const closed = await registry.handleChannelToolCall('channel_close', { channelId: id }, mode); + assert.equal(closed.success, true, `${id}: not refused as a machine's close of an explicit open`); + assert.equal(registry.getDesiredState('discord', id), 'closed'); + assert.equal(registry.speechOpens('discord', id), false, `${id}: speech leaves it closed`); + assert.equal(registry.closedByAgent('discord', id), undefined, `${id}: not the agent's close`); + } + const opensBefore = openCalls.length; + assert.deepEqual(await registry.routeSpeech('sol', 'a reply', 'mode-a'), { delivered: true, channelId: 'mode-a' }); + assert.equal(publishCalls.at(-1)?.channelId, 'mode-a'); + assert.equal(openCalls.length, opensBefore, 'speech opened nothing'); + + // Policy doesn't lift it at the next registration. + registry.setSubscriptionPolicy('discord', 'auto'); + await registry.handleChanged('discord', { + added: [{ id: 'mode-a', type: 'discord', label: '#a', direction: 'bidirectional' }], + } as never); + assert.equal(registry.getDesiredState('discord', 'mode-a'), 'closed'); + // A machine's close doesn't replace it. + await registry.handleChannelToolCall('channel_close', { channelId: 'mode-b', source: 'subscription-gc' }, mode); + assert.equal(registry.speechOpens('discord', 'mode-b'), false, "still the module's"); + // The agent's own close does, and from then on it's the agent's. + await registry.handleChannelToolCall('channel_close', { channelId: 'mode-a' }, agent); + assert.equal(registry.closedByAgent('discord', 'mode-a'), 'closed'); +}); + test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { const { registry, openCalls } = makeRegistry({ delivered: true }); const added = (id: string, raw: string) => ({ diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 593070552..d9960273f 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -265,7 +265,7 @@ test('a send the server refuses carries no closed-channel note', async () => { assert.equal(refused.error, 'Missing Access in channel 444555', "the server's refusal, and nothing about the channel"); }); -test('a send into an open channel, or by send_dm, carries no closed-channel note', async () => { +test('a send into an open channel, by send_dm, or into a channel a module closed, carries no closed-channel note', async () => { { const { registry, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); @@ -278,6 +278,18 @@ test('a send into an open channel, or by send_dm, carries no closed-channel note assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); } + { + // A module closed it, as connectome-host's mentions mode does through ctx.callTool: + // the send leaves it closed, and the module's own tool is the way back. + const { registry, opens, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, { kind: 'module' })).success, true); + const opensBefore = opens.length; + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(!data.includes('[closed channel]'), 'a module\'s close'); + assert.equal(opens.length, opensBefore, 'the send opened nothing'); + assert.equal(registry.isChannelOpen(channelId), false); + } }); test('a send into a closed channel nobody decided about opens it, as channel_open does, and says so', async () => { From 8b8cf64a6b415696527ce778b4af9e00a8f1605a Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 02:40:25 -0400 Subject: [PATCH 28/37] fix(channels): the closed-channel note on send_dm too, as on every way of speaking (Ada-1017, room-203 #123175) #111061's exemption answered the broad rule, under which every DM stayed closed after an answer, so every send_dm's answer would have carried the note. Under the narrow rule the note shows only in a DM the agent closed itself, where it's true, and routed speech there already carries it. One rule for every way of speaking is simpler and loses nothing (the rent pass for #330's rebuild). - The send path asks closedChannelNotes for every send tool. - send-into-closed-channel: the first note test now also expects the note on send_dm into #upkeep, which the agent closed; the case that expected none is gone from the open/module test. - The fragment no longer names an exemption. --- changelog.d/agent-close-holds.breaking.md | 15 +++++++-------- src/framework.ts | 2 +- test/send-into-closed-channel.test.ts | 11 +++-------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 79db2c0c9..666901325 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -17,14 +17,13 @@ gets the line once it's delivered, and a line that comes while another turn is under way waits for that turn's next tool call, where that turn is shown it, or for its end. The agent turns the note off for good with `agent_settings`' new `closed_channel_note`, which is on by - default. A `send_dm`'s answer never carries it, since the tool sends to a DM, and a - conversation agent gets none: `channel_open` is refused to it, and its settings end with its - conversation. In such a channel, the `sleep` tool's default announcement says "I'll still see - messages addressed to me, but won't respond until I wake." A send tool's call into a channel - the agent tuned out, which ended the tune-out before, now leaves it in place, and its answer - says so: "… stays tuned out: your words there don't end the tune-out, so its traffic still - goes to your subconscious until the tune-out ends. tune_out with channelId … and mode "cancel" - resumes normal attention there." The same setting turns that line off. + default. A conversation agent gets none: `channel_open` is refused to it, and its settings end + with its conversation. In such a channel, the `sleep` tool's default announcement says "I'll + still see messages addressed to me, but won't respond until I wake." A send tool's call into a + channel the agent tuned out, which ended the tune-out before, now leaves it in place, and its + answer says so: "… stays tuned out: your words there don't end the tune-out, so its traffic + still goes to your subconscious until the tune-out ends. tune_out with channelId … and mode + "cancel" resumes normal attention there." The same setting turns that line off. - **What the invitation says:** the closed-channel invitation's first option now says what replying does there. In a channel the agent closed or declined itself, it reads as before: "Reply without joining …", with the note that follow-ups that don't address the agent won't diff --git a/src/framework.ts b/src/framework.ts index cb0ea73c5..11714ce43 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18430,7 +18430,7 @@ export class AgentFramework { sendingTurn?.engaged.add(engagedChannel); // Where the agent's words stayed in a channel it closed or // declined itself, or tuned out, the send's answer says so. - const [closedNote] = toolName === 'send_dm' ? [] : this.closedChannelNotes(agentName, [engagedChannel], serverId); + const [closedNote] = this.closedChannelNotes(agentName, [engagedChannel], serverId); const tunedOutNote = this.tunedOutSendNote(agentName, engagedChannel, serverId); const note = closedNote ? `[closed channel] ${closedNote}` : tunedOutNote ? `[tuned out] ${tunedOutNote}` : undefined; if (note) { diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index d9960273f..d4fe7c575 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -157,6 +157,8 @@ test("a send into a channel the agent closed says so, what still reaches the age assert.equal(first.success, true); assert.ok(first.data.endsWith(`\n${UPKEEP_NOTE}`), first.data); assert.ok(first.data.startsWith('{"messageId":"m9"'), "the server's own answer comes first"); + // Every send tool, send_dm included: one rule for every way of speaking. + assert.ok((await reply('send_dm')).data.endsWith(`\n${UPKEEP_NOTE}`), 'send_dm'); // Turned off through agent_settings, for good: kept in the framework's state. const settings = framework.closedChannelNoteSettingsExtension(); @@ -265,19 +267,12 @@ test('a send the server refuses carries no closed-channel note', async () => { assert.equal(refused.error, 'Missing Access in channel 444555', "the server's refusal, and nothing about the channel"); }); -test('a send into an open channel, by send_dm, or into a channel a module closed, carries no closed-channel note', async () => { +test('a send into an open channel, or into a channel a module closed, carries no closed-channel note', async () => { { const { registry, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); assert.ok(!(await reply()).data.includes('[closed channel]'), 'open'); } - { - // send_dm sends to a DM by what the tool is, so its answer carries no - // note even into #upkeep, which the agent closed. - const { registry, reply } = await harness(); - assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); - assert.ok(!(await reply('send_dm')).data.includes('[closed channel]'), 'send_dm'); - } { // A module closed it, as connectome-host's mentions mode does through ctx.callTool: // the send leaves it closed, and the module's own tool is the way back. From 5454bd290fbce2a53263292cf0d32ebe912df3f1 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 04:48:00 -0400 Subject: [PATCH 29/37] fix(channels): af-speak6's round: the latest deliberate close, an open racing a close, decline, sleep words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round on af-speak6 (wf-2077, 26 issues at 8b8cf64a). Ada-1017's calls, room-203 #124544 (all five leans of #124505): - One same-state rule (I1, I18, I26, I3): at the same closed state, the agent's close or decline, or a module's close, records itself over whatever closed record is there; a machine's close never replaces a record. The earlier rule listed what each close replaced and missed 'legacy-recipe-migration', which migrateLegacyPolicy writes for every channel a server lists at its first channels/register: the agent's close or decline of such a channel recorded nothing, so its next reply opened it again (and the invitation's "decline it first" was dropped). Also a replayed 'unknown' source, and a decline after a close (the note said "you closed it"). A module's close now replaces the agent's own ("the latest deliberate close wins"), so a channel main recorded as agent-tool for connectome-host's mentions mode can be re-recorded by setting the mode again. Opens and tune-outs keep main's early return. isCloseDecision() names the agent-or-module set, which speechOpens and the new closedByDecision() share. - I4: openChannelNow marks the channel open after its round trip only while the decision still says open, openForSpeech reports 'opened' only then (so no "[channels] Now open" after the agent's close), and reconcileChannels' open does the same. The close side isn't mirrored: a wrong 'closed' flag heals on the next channels/incoming (markAccepted), a wrong 'open' flag doesn't. - I6: channel_decline is refused only for a channel open at the transport, so a channel decided open whose open failed can be declined, as the invitation's option 1 advises. - I10/I24: the invitation's no-target variant keeps its send-tool wording (main's "isn't available … Join it" is false where a send tool replies without joining), and where a reply doesn't join it ends with the follow-ups note, as the target variant does. - I14: stored machine closes keep their source (text only). Plain fixes: - I2: the sleep words read closedByDecision (agent or module close, and not open at the transport), so a module-closed channel gets "addressed to me" too; a refused close (still open) keeps the plain words. - I5: the send path's note reads isChannelOpen and the label on the server the send went to (isChannelOpen/getDescriptor take an optional serverId). Routed speech and the sleep post already read the entry routeSpeech uses (findChannelEntry). - I15: closedChannelNoteFor's docstring names module closes and tune-outs. - I22: the host section names that a module's unknown machine source is refused on an already-closed channel too. - Tests: the send test file's header (I8); the in-flight send test waits until the send is at the server before the close and counts opens from before it (I7); the harness settles a send's open by awaiting it, not a setImmediate tick (I17); a refused send into an undecided channel opens nothing (I11); a shared-id test for I5; the sleep test's title (I25), its result shape (I16), a module-closed and a refused-close channel (I2); housekeeping beside subscription-gc (I23); a registry over a lifecycle log for the same-state rule (migrated, replayed 'unknown', decline after close, module after agent, machine replaces none) and for an open racing the agent's close (speech and reconcile); a decline of a channel whose open failed. - Changelog fragments rewritten for all of the above. --- changelog.d/agent-close-holds.breaking.md | 29 ++-- changelog.d/agent-close-holds.fixed.md | 22 +++- src/framework.ts | 41 +++--- src/mcpl/channel-registry.ts | 74 ++++++++--- test/channel-registry-routing.test.ts | 154 +++++++++++++++++++++- test/send-into-closed-channel.test.ts | 136 +++++++++++++++---- test/trunk-channel-routing.test.ts | 2 +- 7 files changed, 373 insertions(+), 85 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 666901325..974b73f51 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -18,16 +18,17 @@ waits for that turn's next tool call, where that turn is shown it, or for its end. The agent turns the note off for good with `agent_settings`' new `closed_channel_note`, which is on by default. A conversation agent gets none: `channel_open` is refused to it, and its settings end - with its conversation. In such a channel, the `sleep` tool's default announcement says "I'll - still see messages addressed to me, but won't respond until I wake." A send tool's call into a + with its conversation. In such a channel, and in one a module closed, the `sleep` tool's + default announcement says "I'll still see messages addressed to me, but won't respond until I + wake." A send tool's call into a channel the agent tuned out, which ended the tune-out before, now leaves it in place, and its answer says so: "… stays tuned out: your words there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out ends. tune_out with channelId … and mode "cancel" resumes normal attention there." The same setting turns that line off. - **What the invitation says:** the closed-channel invitation's first option now says what - replying does there. In a channel the agent closed or declined itself, it reads as before: - "Reply without joining …", with the note that follow-ups that don't address the agent won't - reach it. In any other closed channel, where a reply opens the channel, it reads "1. Reply — + replying does there. In a channel the agent closed or declined itself, or a module closed, it + reads as before: "Reply without joining …", with the note that follow-ups that don't address + the agent won't reach it. In any other closed channel, where a reply opens the channel, it reads "1. Reply — … it will be delivered there, and replying joins the channel, as option 2 does. To reply without joining, decline it first (option 3): a channel you've declined stays closed when you speak there." Before, it offered "Reply without joining" there too, though the reply opened @@ -53,9 +54,15 @@ where it was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, but gives no note, since the module owns the way back. In connectome-host that's the `mentions` channel mode: a reply to a mention in a channel in that mode stays a reply, and the invitation - there offers "Reply without joining". Policy doesn't lift such a close, a machine's close - doesn't replace it, and the agent's own close does. A module's close is never refused as a - machine's close of an explicit open; only one naming a machine source is. + there offers "Reply without joining". Policy doesn't lift such a close, and a machine's close + doesn't replace it. Of the agent's close and a module's, the later one is the one on record. A + module's close is never refused as a machine's close of an explicit open; only one naming a + machine source is. A module's `channel_close` naming an unknown machine source is now refused on + a channel that's already closed too, where it returned "already closed". + - **Who needs to act:** a module's close made before this change was recorded as `agent-tool`, + which can't be told apart from the agent's own, so after the upgrade such a channel reads as + one the agent closed, and the agent's words there carry the note. Setting the channel's mode + again (connectome-host's `channel_mode` with `mentions`) records the close as the module's. - **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` is removed. `resolveSendTarget(channelId, serverId?)` returns the descriptor id of the channel a send went to, from a composite or raw id, and opens nothing. `speechOpens(serverId, channelId)` says whether @@ -63,8 +70,10 @@ declined it, a module closed it, it's tuned out, or its server's grant lacks `channels.lifecycle`. `openForSpeech(serverId, channelId, source)` opens it where `speechOpens` says so, as the - framework's send path and `routeSpeech` do. `closedByAgent(serverId, channelId)` and - `canOpenChannels(serverId)` are new. + framework's send path and `routeSpeech` do. `closedByAgent(serverId, channelId)`, + `closedByDecision(serverId, channelId)` and `canOpenChannels(serverId)` are new. + `isChannelOpen(channelId, serverId?)` and `getDescriptor(channelId, serverId?)` read the channel + on the server named, where one is given. - **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so the first request after the upgrade that carries those tools misses the cache from its tool block on. diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index fd9e00577..085c9d8d9 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -9,15 +9,25 @@ only logged it. - A send tool's call into a tuned-out channel no longer ends the tune-out. It used to set the channel's decision to open, which ended the tune-out epoch, and announced "[channels] Now open". -- The agent's `channel_close` or `channel_decline` of a channel nobody had decided about, which - is closed by default, or of one a machine closed, is now recorded as its decision, so neither - subscription policy, a server's `initiallyOpen` nor the agent's own speech there opens it later. - Before, the channel kept its default, which policy and `initiallyOpen` lift, or the machine's - source. A machine close of a channel nobody decided about, +- The agent's `channel_close` or `channel_decline` of a channel that's already closed is now + recorded as its decision, whatever closed it: its default, the retired recipe policy's one-time + migration (every channel a server listed at its first registration), a machine, a module, or a + log entry whose source couldn't be read. So neither subscription policy, a server's + `initiallyOpen` nor the agent's own speech there opens it later, and a decline after a close is + the decision on record, as a close after a decline is. Before, the channel kept the record it + had. A machine close of a channel nobody decided about, such as connectome-host's `subscription-gc`, still records no decision: the channel keeps its default, so policy can still open it. That now holds for a channel with no record at all, one registered lazily or on a server whose grant lacks `channels.lifecycle`, where a machine close - used to record its own source, which policy never lifts (#417). + used to record its own source, which policy never lifts (#417). Records written before this + change keep their source, so a channel a machine closed before the upgrade still isn't opened by + policy. +- When the agent closes a channel while an open is still on its way, from its speech, a send or a + reconcile after a reconnect, the channel is no longer left marked open, and a send no longer + announces "[channels] Now open" after the close. The close goes to the server after the open, + so the channel ends closed, and the live state now follows the decision. +- `channel_decline` of a channel decided open whose open failed, which is closed, now records the + decline. Before, it was refused as "already open". - `channel_close`'s description and the "[channels] Now open" notice's last sentence now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. diff --git a/src/framework.ts b/src/framework.ts index 11714ce43..b299d7ffa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4180,10 +4180,13 @@ export class AgentFramework { /** What an agent is told when their words go to a channel that stays closed * because it closed or declined the channel itself (ChannelRegistry - * closedByAgent), or undefined where nothing needs saying: speech opens - * every other closed channel, as it always did (openForSpeech), so the - * agent meets the change only here, where speaking used to reopen what it - * had closed. What a closed channel still delivers beyond messages that + * closedByAgent), or undefined where nothing needs saying. Speech also + * leaves a channel a module closed, and a tuned-out one, as they are + * (speechOpens), but there the module's own tool, or tune_out, is the way + * back, and a tuned-out send gets its own line (tunedOutSendNote). Every + * other closed channel opens on speech, as it always did (openForSpeech), + * so the agent meets the change here, where speaking used to reopen what it + * had closed. The channel is read on the server the words went to. What a closed channel still delivers beyond messages that * address the agent is the connector's (slack-mcpl delivers ambient * traffic by its own subscription), so the note promises only those, and * what channel_open does where the server's grant lets it open channels @@ -4193,10 +4196,10 @@ export class AgentFramework { * that muted it knows. */ private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { const registry = this.channelRegistry; - if (!registry || !serverId || registry.isChannelOpen(channelId)) return undefined; + if (!registry || !serverId || registry.isChannelOpen(channelId, serverId)) return undefined; const how = registry.closedByAgent(serverId, channelId); if (!how) return undefined; - const place = this.channelNotePlace(channelId); + const place = this.channelNotePlace(channelId, serverId); const reach = 'Messages there that address you, such as an @-mention, still reach you'; const open = registry.canOpenChannels(serverId) ? `; channel_open with channelId "${channelId}" and serverId "${serverId}" opens it.` @@ -4205,8 +4208,8 @@ export class AgentFramework { } /** A channel as a note names it: its label and id, or its id alone. */ - private channelNotePlace(channelId: string): string { - const label = this.channelRegistry?.getDescriptor(channelId)?.label; + private channelNotePlace(channelId: string, serverId?: string): string { + const label = this.channelRegistry?.getDescriptor(channelId, serverId)?.label; return label && label !== channelId ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId; @@ -4227,7 +4230,7 @@ export class AgentFramework { if (!registry || this.conversationAgentHomes.has(agentName)) return undefined; if (registry.getDesiredState(serverId, channelId) !== 'tuned-out') return undefined; if (!this.closedChannelNoteOn(agentName)) return undefined; - return `${this.channelNotePlace(channelId)} stays tuned out: your words there don't end the tune-out, so its ` + + return `${this.channelNotePlace(channelId, serverId)} stays tuned out: your words there don't end the tune-out, so its ` + 'traffic still goes to your subconscious until the tune-out ends. ' + `tune_out with channelId "${channelId}" and mode "cancel" resumes normal attention there.`; } catch (err) { @@ -9965,7 +9968,9 @@ export class AgentFramework { `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` : `1. Reply${replyJoins ? '' : ' without joining'} — no single-word target reaches this channel unambiguously, ` + `so a ">>" prefix can't name it; check whether ${opts.serverId} exposes a send tool that can address it.` + - (replyJoins ? ` Replying there joins the channel, as option 2 does. ${withoutJoining}\n` : '\n'); + (replyJoins + ? ` Replying there joins the channel, as option 2 does. ${withoutJoining}\n` + : ` Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n`); const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -18889,16 +18894,18 @@ export class AgentFramework { } } // The announcement opens a closed channel as any speech does, unless the - // agent closed or declined it itself (openForSpeech): there it stays - // closed, so the announcement says the agent will still see messages - // addressed to it. That's decided from the channel as the announcement - // goes out, since it doesn't depend on the post landing, which stays - // fire-and-forget. Those words are true in an open channel too, so - // they're the ones used when the channel's state can't be read. + // agent or a module decided to keep it closed (openForSpeech): there it + // stays closed, so the announcement says the agent will still see + // messages addressed to it. That's decided from the channel as the + // announcement goes out, on the server its post goes to (routeSpeech's + // first entry with that id), since it doesn't depend on the post landing, + // which stays fire-and-forget. Those words are true in an open channel + // too, so they're the ones used when the channel's state can't be read. let staysClosed = false; if (destination && this.channelRegistry) { try { - staysClosed = this.closedChannelNoteFor(destination, this.channelRegistry.getChannelServerId(destination)) !== undefined; + const serverId = this.channelRegistry.getChannelServerId(destination); + staysClosed = serverId !== null && this.channelRegistry.closedByDecision(serverId, destination); } catch (err) { staysClosed = true; console.error(`[sleep] ${agentName}: whether ${destination} stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index a22d110f5..01f3b026b 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -579,6 +579,11 @@ export const AGENT_CLOSE_SOURCES: ReadonlySet = new Set(['agent-tool', ' * part of what the module did. */ export const MODULE_CLOSE_SOURCE = 'module-tool'; +/** A close the agent or a module decided: speech never undoes it. */ +function isCloseDecision(source: string): boolean { + return AGENT_CLOSE_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE; +} + export class ChannelRegistry { private serverRegistry: McplServerRegistry; private featureSetManager: FeatureSetManager; @@ -1355,12 +1360,20 @@ export class ChannelRegistry { return !!entry && !(entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered; } - getDescriptor(channelId: string): ChannelDescriptor | undefined { - return this.findChannelEntry(channelId)?.descriptor; + /** A channel's descriptor: on the server named, or else the first server + * that has a channel with this id. */ + getDescriptor(channelId: string, serverId?: string): ChannelDescriptor | undefined { + return this.entryFor(channelId, serverId)?.descriptor; + } + + /** Whether a channel is open at the transport: on the server named, or else + * the first server that has a channel with this id. */ + isChannelOpen(channelId: string, serverId?: string): boolean { + return this.entryFor(channelId, serverId)?.open === true; } - isChannelOpen(channelId: string): boolean { - return this.findChannelEntry(channelId)?.open === true; + private entryFor(channelId: string, serverId?: string): ChannelEntry | undefined { + return serverId === undefined ? this.findChannelEntry(channelId) : this.channels.get(`${serverId}:${channelId}`); } getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { @@ -1964,21 +1977,17 @@ export class ChannelRegistry { // registered lazily, or on a server whose grant lacks channels.lifecycle), // it records the default that channel already had, so policy and // bootstrap can still lift it (#417). - let recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source; + const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source; if (current?.state === desired) { - // The same state keeps the decision on record, except that a close - // someone decided replaces one nobody did, so it sticks: policy and + // The same state keeps the record, except that the latest deliberate + // close wins: the agent's own close or decline, or a module's close, + // records itself over whatever closed record is there (a pure default, + // the retired recipe's migration, a machine's close, a source a replay + // couldn't read, or another deliberate close), so it sticks: policy and // bootstrap lift only 'default-closed', and speech opens only what - // nobody decided to keep closed (speechOpens). The agent's own close or - // decline replaces a pure default, a machine's close or a module's; a - // module's close replaces a pure default or a machine's. A machine - // source never replaces one, so what nobody decided stays liftable. - const undecided = current.source === 'default-closed' || MACHINE_CLOSE_SOURCES.has(current.source); - const replaces = AGENT_CLOSE_SOURCES.has(source) - ? undecided || current.source === MODULE_CLOSE_SOURCE - : source === MODULE_CLOSE_SOURCE && undecided; - recorded = replaces ? source : current.source; - if (recorded === current.source) return; + // nobody decided to keep closed (speechOpens). A machine's close never + // replaces a record, so what nobody decided stays liftable. + if (desired !== 'closed' || !isCloseDecision(source) || current.source === source) return; } this.desiredStates.set(key, { state: desired, source: recorded }); this.appendLifecycleEvent({ @@ -2273,7 +2282,10 @@ export class ChannelRegistry { type: channel.type, address: channel.address, }); - if (entry) entry.open = true; + // As in openChannelNow: a close decided meanwhile went to the server + // after this open, so the flag follows the decision. + const now = this.getDesiredState(serverId, channel.id); + if (entry && (now === 'open' || now === 'tuned-out')) entry.open = true; } catch (err) { if (entry) entry.open = false; this.emitTraceFn({ @@ -2451,7 +2463,10 @@ export class ChannelRegistry { address: entry.descriptor.address, ...(history ? { history } : {}), }); - entry.open = true; + // A close decided while the open was on its way (the agent's channel_close + // racing an open a send started) went to the server after it, so the + // channel ends closed: the live flag follows the decision. + if (this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') entry.open = true; return result; } @@ -2663,6 +2678,17 @@ export class ChannelRegistry { * its channel_close, 'declined' for its channel_decline. Its speech there * leaves such a channel closed (openForSpeech). */ + /** Whether this channel stays closed when the agent's words go there + * because the agent or a module decided to keep it closed: the agent's own + * close or decline, or a module's close, which speech never opens + * (speechOpens). False for a channel open at the transport. */ + closedByDecision(serverId: string, channelId: string): boolean { + const entry = this.channels.get(`${serverId}:${channelId}`); + if (!entry || entry.open) return false; + const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); + return current?.state === 'closed' && isCloseDecision(current.source); + } + closedByAgent(serverId: string, channelId: string): 'closed' | 'declined' | undefined { const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); if (current?.state !== 'closed') return undefined; @@ -2695,7 +2721,7 @@ export class ChannelRegistry { if (!entry || entry.open || !this.canOpenChannels(serverId)) return false; const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); if (current?.state === 'tuned-out') return false; - return !(current?.state === 'closed' && (AGENT_CLOSE_SOURCES.has(current.source) || current.source === MODULE_CLOSE_SOURCE)); + return !(current?.state === 'closed' && isCloseDecision(current.source)); } /** @@ -2716,6 +2742,9 @@ export class ChannelRegistry { if (!this.speechOpens(serverId, channelId)) return 'not-opened'; try { await this.openChannelNow(entry, source); + // A close the agent decided while the open was on its way stands, and + // nothing reports the channel as opened. + if (this.getDesiredState(serverId, channelId) !== 'open') return 'not-opened'; this.emitTraceFn({ type: 'mcpl:channel-opened-by-send', serverId, channelId }); return 'opened'; } catch (err) { @@ -2927,7 +2956,10 @@ export class ChannelRegistry { if (!entry) { return { success: false, error: resolved.error, isError: true }; } - if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') { + // Refused only for a channel open at the transport: a channel decided + // open whose open failed is closed, and the invitation that comes from it + // offers the decline (option 3) as the way to reply without joining. + if (entry.open) { return { success: false, error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`, diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 2382fba98..51a8e73c1 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -432,15 +432,17 @@ test("speech opens a channel a machine closed, and the agent's own close of it t added: [ { id: 'gc-a', type: 'discord', label: '#a', direction: 'bidirectional' }, { id: 'gc-b', type: 'discord', label: '#b', direction: 'bidirectional' }, + { id: 'hk-a', type: 'discord', label: '#h', direction: 'bidirectional' }, ], } as never); const agent = { kind: 'agent' as const, agentName: 'sol' }; - const gc = { kind: 'module' as const, moduleName: 'subscription-gc' }; - for (const id of ['gc-a', 'gc-b']) { + // Both machine sources: subscription-gc's idle close and a housekeeping close. + for (const [id, source] of [['gc-a', 'subscription-gc'], ['gc-b', 'subscription-gc'], ['hk-a', 'housekeeping']] as const) { assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: id }, agent)).success, true); assert.equal((await registry.handleChannelToolCall( - 'channel_close', { channelId: id, source: 'subscription-gc', overrideExplicitOpen: true }, gc)).success, true); + 'channel_close', { channelId: id, source, overrideExplicitOpen: true }, { kind: 'module', moduleName: source })).success, true); assert.equal(registry.closedByAgent('discord', id), undefined, 'a machine close is no decision of the agent'); + assert.equal(registry.speechOpens('discord', id), true, `speech opens what ${source} closed`); } // The agent closes one of them itself: its close replaces the machine's. const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'gc-b' }, agent); @@ -450,10 +452,12 @@ test("speech opens a channel a machine closed, and the agent's own close of it t await registry.routeSpeech('sol', 'answering', 'gc-a'); await registry.routeSpeech('sol', 'answering', 'gc-b'); + await registry.routeSpeech('sol', 'answering', 'hk-a'); - assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['gc-a'], 'speech opens only the machine-closed one'); + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['gc-a', 'hk-a'], 'speech opens only the machine-closed ones'); assert.equal(lookup('gc-a')!.open, true); assert.equal(lookup('gc-b')!.open, false); + assert.equal(lookup('hk-a')!.open, true); }); test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { @@ -620,6 +624,148 @@ test("a module's close, made with no machine source, is the module's: speech lea // The agent's own close does, and from then on it's the agent's. await registry.handleChannelToolCall('channel_close', { channelId: 'mode-a' }, agent); assert.equal(registry.closedByAgent('discord', 'mode-a'), 'closed'); + // The latest deliberate close wins: the mode set again makes it the module's. + await registry.handleChannelToolCall('channel_close', { channelId: 'mode-a' }, mode); + assert.equal(registry.closedByAgent('discord', 'mode-a'), undefined, "the module's again"); + assert.equal(registry.speechOpens('discord', 'mode-a'), false); +}); + +/** A registry over a lifecycle log, as Chronicle keeps it, with a server whose + * channels/open answers can be held. */ +function loggedRegistry(events: unknown[]) { + const openCalls: Array<{ channelId?: string }> = []; + let openHold: Promise | null = null; + let openReached: () => void = () => {}; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsOpen: async (params: { channelId?: string }) => { + openCalls.push(params); + openReached(); + if (openHold) await openHold; + return {}; + }, + sendChannelsClose: async () => ({}), + sendChannelsPublish: async () => ({ delivered: true }), + }; + const store = { + registerState: () => {}, + getStateJson: (id: string) => (id === 'mcpl/channel-lifecycle' ? events : null), + appendToStateJson: (id: string, event: unknown) => { if (id === 'mcpl/channel-lifecycle') events.push(event); }, + }; + // Each open speech reports, as the framework's "[channels] Now open" notice hears of it. + const autoOpened: Array<{ channels: Array<{ channelId: string }> }> = []; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, + { store: store as never, onChannelAutoOpened: (info) => { autoOpened.push(info); } }, + ); + return { + registry, openCalls, autoOpened, + /** Hold every channels/open answer until the returned release; resolves `reached` at the first one. */ + holdOpens: () => { + let release!: () => void; + openHold = new Promise((resolve) => { release = resolve; }); + const reached = new Promise((resolve) => { openReached = resolve; }); + return { reached, release: () => { openHold = null; release(); } }; + }, + }; +} + +const channelsOf = (...ids: string[]) => + ids.map((id) => ({ id, type: 'discord', label: `#${id}`, direction: 'bidirectional' as const })); + +test("the latest deliberate close records itself over any closed record: the retired recipe's migration, a source a replay couldn't read, the agent's own earlier close; a machine's close replaces none", async () => { + // A record replayed from the log with no source reads as 'unknown'. + const events: unknown[] = [{ kind: 'desired-state', serverId: 'discord', channelId: 'replayed', desired: 'closed', timestamp: '2026-10-01T00:00:00.000Z' }]; + const { registry, openCalls } = loggedRegistry(events); + // The server's first registration consumes the retired recipe policy ('manual'): + // every channel it lists that has no record is recorded closed by that migration. + await registry.handleRegister('discord', { channels: channelsOf('migrated-a', 'migrated-b', 'migrated-c', 'replayed') } as never); + for (const id of ['migrated-a', 'migrated-b', 'migrated-c', 'replayed']) { + assert.equal(registry.getDesiredState('discord', id), 'closed', id); + assert.equal(registry.speechOpens('discord', id), true, `${id}: nobody decided it, so speech would open it`); + } + const agent = { kind: 'agent' as const, agentName: 'sol' }; + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'migrated-a' }, agent)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_decline', { channelId: 'migrated-b', messageId: 'm1' }, agent)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'migrated-c' }, { kind: 'module' })).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'replayed' }, agent)).success, true); + assert.equal(registry.closedByAgent('discord', 'migrated-a'), 'closed'); + assert.equal(registry.closedByAgent('discord', 'migrated-b'), 'declined'); + assert.equal(registry.closedByAgent('discord', 'migrated-c'), undefined, "a module's close"); + assert.equal(registry.closedByAgent('discord', 'replayed'), 'closed'); + // A decline after the agent's own close is the latest decision, and the note says so. + assert.equal((await registry.handleChannelToolCall('channel_decline', { channelId: 'migrated-a', messageId: 'm2' }, agent)).success, true); + assert.equal(registry.closedByAgent('discord', 'migrated-a'), 'declined'); + // A machine's close replaces none of them. + const gc = { kind: 'module' as const, moduleName: 'subscription-gc' }; + for (const id of ['migrated-a', 'migrated-c']) { + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: id, source: 'subscription-gc' }, gc)).success, true); + } + assert.equal(registry.closedByAgent('discord', 'migrated-a'), 'declined'); + assert.equal(registry.speechOpens('discord', 'migrated-c'), false, "still the module's"); + + const opensBefore = openCalls.length; + for (const id of ['migrated-a', 'migrated-b', 'migrated-c', 'replayed']) { + assert.deepEqual(await registry.routeSpeech('sol', 'answering', id), { delivered: true, channelId: id }); + } + assert.equal(openCalls.length, opensBefore, 'speech opened none of them'); + // Each decision went to the log. + const logged = (id: string) => (events as Array<{ kind?: string; channelId?: string; source?: string }>) + .filter((e) => e.kind === 'desired-state' && e.channelId === id).at(-1)?.source; + assert.deepEqual(['migrated-a', 'migrated-b', 'migrated-c', 'replayed'].map(logged), + ['invitation-declined', 'invitation-declined', 'module-tool', 'agent-tool']); +}); + +test("an open still on its way when the agent closes the channel leaves it closed, whether speech or a reconcile sent it", async () => { + const { registry, holdOpens, autoOpened } = loggedRegistry([]); + await registry.handleRegister('discord', { channels: channelsOf('spoken', 'reconciled') } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + + // Speech opens 'spoken', nobody decided about it; the agent closes it while the open is on its way. + let held = holdOpens(); + const speaking = registry.routeSpeech('sol', 'answering', 'spoken'); + await held.reached; + assert.equal(registry.getDesiredState('discord', 'spoken'), 'open', "the open's intent is recorded"); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'spoken' }, agent)).success, true); + held.release(); + assert.deepEqual(await speaking, { delivered: true, channelId: 'spoken' }); + assert.equal(registry.isChannelOpen('spoken'), false, 'the close, decided while the open was on its way, stands'); + assert.equal(registry.getDesiredState('discord', 'spoken'), 'closed'); + assert.deepEqual(autoOpened, [], 'and nothing reports the channel as opened'); + assert.equal(await registry.openForSpeech('discord', 'spoken', 'opened-by-delivery'), 'not-opened'); + + // A reconcile reopens 'reconciled', which the agent decided open; the agent closes it meanwhile. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'reconciled' }, agent)).success, true); + held = holdOpens(); + const reregistering = registry.handleRegister('discord', { channels: channelsOf('reconciled') } as never); + await held.reached; + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'reconciled' }, agent)).success, true); + held.release(); + await reregistering; + assert.equal(registry.isChannelOpen('reconciled'), false); + assert.equal(registry.getDesiredState('discord', 'reconciled'), 'closed'); +}); + +test('a channel decided open whose open failed is closed at the transport, so the agent can decline it, and speech then leaves it closed', async () => { + const { registry, openCalls, setFailOpens } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { added: channelsOf('down') } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + setFailOpens(true); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'down' }, agent)).success, false); + assert.equal(registry.getDesiredState('discord', 'down'), 'open'); + assert.equal(registry.isChannelOpen('down'), false); + const declined = await registry.handleChannelToolCall('channel_decline', { channelId: 'down', messageId: 'm1' }, agent); + assert.equal(declined.success, true, declined.error); + assert.equal(registry.closedByAgent('discord', 'down'), 'declined'); + setFailOpens(false); + const opensBefore = openCalls.length; + assert.deepEqual(await registry.routeSpeech('sol', 'answering', 'down'), { delivered: true, channelId: 'down' }); + assert.equal(openCalls.length, opensBefore, 'speech leaves it closed'); + // A channel open at the transport is still refused. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'down' }, agent)).success, true); + const refused = await registry.handleChannelToolCall('channel_decline', { channelId: 'down', messageId: 'm2' }, agent); + assert.equal(refused.success, false); + assert.match(refused.error ?? '', /already open/); }); test('resolveSendTarget names the channel a send went to and guesses nothing', async () => { diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index d4fe7c575..d35a773f9 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -1,15 +1,15 @@ /** * Speaking never undoes the agent's own decision to keep a channel closed: an * explicit send tool's successful call posts (the server sends it), and opens - * a closed channel, as channel_open does, only where nobody decided to keep it - * closed. A channel the agent closed or declined stays closed, a channel whose - * open failed isn't opened again, and a tuned-out channel stays tuned out. - * The call records the channel as engaged this turn, by its descriptor id, - * for the mid-turn re-pin. Where the words land in a channel the agent closed - * or declined itself, the send's answer, or the turn's delivery line for - * routed speech, says the channel stays closed, what still reaches the agent - * from it, and how to open it, until the agent turns that note off with - * agent_settings. + * a closed channel, as channel_open does, unless the agent closed or declined + * it itself or a module closed it, which stay closed, as a tuned-out channel + * stays tuned out. A channel nobody decided about, one a machine closed, and + * one decided open whose open failed are opened, as on main. The call records + * the channel as engaged this turn, by its descriptor id, for the mid-turn + * re-pin. Where the words land in a channel the agent closed or declined + * itself, the send's answer, or the turn's delivery line for routed speech, + * says the channel stays closed, what still reaches the agent from it, and + * how to open it, until the agent turns that note off with agent_settings. */ import { test } from 'node:test'; @@ -34,13 +34,17 @@ const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: you c 'To stop this note, set closed_channel_note to false with agent_settings.'; /** A real ChannelRegistry with one registered channel, and a framework with - * just what dispatchMcplToolCall reads. */ -async function harness() { + * just what dispatchMcplToolCall reads. With sharedIdFirst, a second server, + * discord2, registers a channel with the same id first, so a lookup by bare + * id finds that one. */ +async function harness(opts: { sharedIdFirst?: boolean } = {}) { // Every channels/open attempt, including one the server refuses. const opens: Array<{ channelId?: string }> = []; let refuseOpens = false; - // While set, the send's result waits for it. + // While set, the send's result waits for it; reaching it resolves sendReached. let hold: Promise | null = null; + let reached!: () => void; + let sendReached = new Promise((resolve) => { reached = resolve; }); // The raw channel id the send's answer names. let sentTo = '444555'; // While set, the server refuses the send, as discord-mcpl does without Send Messages. @@ -52,6 +56,7 @@ async function harness() { id: 'discord', grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), async sendToolsCall() { + reached(); if (hold) await hold; if (refuseSends) { return { content: [{ type: 'text', text: `Missing Access in channel ${sentTo}` }], isError: true }; @@ -75,9 +80,23 @@ async function harness() { }, }; const serverRegistry = { - getServer: (id: string) => (id === 'discord' ? server : undefined), + getServer: (id: string) => (id === 'discord' || id === 'discord2' ? server : undefined), } as unknown as McplServerRegistry; const registry = new ChannelRegistry(serverRegistry, {} as FeatureSetManager, () => {}, () => {}); + if (opts.sharedIdFirst) { + await registry.handleChanged('discord2', { + added: [{ id: channelId, type: 'discord', label: '#elsewhere', direction: 'bidirectional', + address: { guildId: 'g2', channelId: '444555' } }], + } as never); + } + // Every open a send starts, which the send's answer doesn't wait for. + const speechOpens: Promise[] = []; + const openForSpeech = registry.openForSpeech.bind(registry); + registry.openForSpeech = ((...args: Parameters) => { + const opening = openForSpeech(...args); + speechOpens.push(opening); + return opening; + }) as typeof openForSpeech; await registry.handleChanged('discord', { added: [{ id: channelId, type: 'discord', label: '#upkeep', direction: 'bidirectional', address: { guildId: 'g1', channelId: '444555' } }], @@ -131,15 +150,20 @@ async function harness() { 'discord', ); }); - // Let anything the send started on its own settle before the caller looks. - await new Promise((resolve) => setImmediate(resolve)); + // Let the open the send started on its own settle, and what follows it, + // before the caller looks. + await Promise.allSettled(speechOpens.splice(0)); return out; } return { registry, framework, opens, windowMessages, reply, published, setRefuseOpens: (v: boolean) => { refuseOpens = v; }, - setHold: (p: Promise | null) => { hold = p; }, + setHold: (p: Promise | null) => { + hold = p; + sendReached = new Promise((resolve) => { reached = resolve; }); + }, + sendReached: () => sendReached, setSentTo: (raw: string) => { sentTo = raw; }, setRefuseSends: (v: boolean) => { refuseSends = v; }, setRefuseCloses: (v: boolean) => { refuseCloses = v; }, @@ -267,6 +291,35 @@ test('a send the server refuses carries no closed-channel note', async () => { assert.equal(refused.error, 'Missing Access in channel 444555', "the server's refusal, and nothing about the channel"); }); +test('a send the server refuses into a closed channel nobody decided about opens nothing and says nothing about it', async () => { + const { registry, opens, windowMessages, reply, setRefuseSends } = await harness(); + assert.equal(registry.speechOpens('discord', channelId), true, 'a reply that went through would open it'); + const before = registry.getDesiredState('discord', channelId); + setRefuseSends(true); + const opensBefore = opens.length; + const refused = await reply(); + assert.equal(refused.success, false); + assert.equal(refused.error, 'Missing Access in channel 444555'); + assert.equal(opens.length, opensBefore, 'nothing was posted, so nothing opens'); + assert.equal(registry.isChannelOpen(channelId), false); + assert.equal(registry.getDesiredState('discord', channelId), before, 'and no intent to open is recorded'); + assert.equal(registry.speechOpens('discord', channelId), true, 'a later reply would still open it'); + assert.deepEqual(windowMessages, [], 'no notice either'); +}); + +test("a send's note reads the channel on the server the words went to, not one on another server with the same id", async () => { + const { registry, reply } = await harness({ sharedIdFirst: true }); + // discord2's channel with this id is open, and found first by bare id. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId, serverId: 'discord2' }, origin)).success, true); + assert.equal(registry.isChannelOpen(channelId), true, 'by bare id, the open one on discord2'); + // On discord, where the reply goes, the agent closed it. + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId, serverId: 'discord' }, origin)).success, true); + assert.equal(registry.isChannelOpen(channelId, 'discord'), false); + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(data.endsWith(UPKEEP_NOTE), data); +}); + test('a send into an open channel, or into a channel a module closed, carries no closed-channel note', async () => { { const { registry, reply } = await harness(); @@ -384,7 +437,6 @@ test('a reply_message into a channel decided open whose open failed opens it aga const opensBefore = opens.length; const { success, data } = await reply(); - await new Promise((resolve) => setImmediate(resolve)); assert.equal(success, true); assert.ok(!data.includes('[closed channel]'), 'no note: the agent decided this channel open'); @@ -426,15 +478,17 @@ test('a send into a tuned-out channel leaves the tune-out in place, and its answ }); test('a reply_message still in flight when the agent closes its channel leaves it closed', async () => { - const { registry, opens, reply, setHold } = await harness(); + const { registry, opens, reply, setHold, sendReached } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); let release!: () => void; setHold(new Promise((resolve) => { release = resolve; })); + const opensBefore = opens.length; const replying = reply(); - // The same round closes the channel while the reply is in flight. + // The reply is at the server, waiting for its answer, when the same round + // closes the channel. + await sendReached(); assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); release(); - const opensBefore = opens.length; const { success, data } = await replying; assert.equal(success, true); assert.equal(registry.isChannelOpen(channelId), false, 'the close, made while the reply was in flight, stands'); @@ -497,8 +551,8 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat } }); -test("a sleep announced into a channel the agent closed says only what addresses it reaches it; anywhere else it opens as speech does", async () => { - const { registry, framework, published, opens } = await harness(); +test("a sleep announced into a channel the agent or a module closed says the agent will still see messages addressed to it; elsewhere the post opens the channel as speech does, and says it will still see messages", async () => { + const { registry, framework, published, opens, setRefuseCloses } = await harness(); await registry.handleChanged('discord', { added: [{ id: 'discord:g1:777888', type: 'discord', label: '#lobby', direction: 'bidirectional', address: { guildId: 'g1', channelId: '777888' } }], @@ -537,7 +591,7 @@ test("a sleep announced into a channel the agent closed says only what addresses let slept = await sleep({ seconds: 600 }); assert.equal(slept.success, true); assert.equal(slept.endTurn, true); - assert.equal(slept.data.closedChannelNote, undefined, 'the sleep result carries no note'); + assert.deepEqual(Object.keys(slept.data).sort(), ['sleepingFor', 'timeZone', 'until', 'untilLocal'], 'the sleep result carries no note'); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); assert.equal(registry.isChannelOpen(channelId), false, 'it stays closed'); @@ -552,12 +606,42 @@ test("a sleep announced into a channel the agent closed says only what addresses framework.closedChannelNoteSettingsExtension().reset('sol'); // When who closed the channel can't be read, the words are ones true either way. - const closedByAgent = registry.closedByAgent.bind(registry); - registry.closedByAgent = () => { throw new Error('the registry is unreadable'); }; + const closedByDecision = registry.closedByDecision.bind(registry); + registry.closedByDecision = () => { throw new Error('the registry is unreadable'); }; slept = await sleep({ seconds: 600 }); - registry.closedByAgent = closedByAgent; + registry.closedByDecision = closedByDecision; assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + // A channel a module closed stays closed too, as connectome-host's mentions mode keeps it. + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:121212', type: 'discord', label: '#mentions-only', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '121212' } }], + } as never); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'discord:g1:121212' }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'discord:g1:121212' }, { kind: 'module' })).success, true); + routing.locus = 'discord:g1:121212'; + const opensBeforeModule = opens.length; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:121212', text: addressed }]); + assert.equal(opens.length, opensBeforeModule, 'the post opened nothing'); + assert.equal(registry.isChannelOpen('discord:g1:121212'), false); + + // A channel the agent closed whose server refused the close is still open, so all of it still reaches the agent. + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:343434', type: 'discord', label: '#refused', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '343434' } }], + } as never); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'discord:g1:343434' }, origin)).success, true); + setRefuseCloses(true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'discord:g1:343434' }, origin)).success, false); + setRefuseCloses(false); + assert.equal(registry.closedByAgent('discord', 'discord:g1:343434'), 'closed', 'the close is on record'); + assert.equal(registry.isChannelOpen('discord:g1:343434'), true, 'and the channel is still open'); + routing.locus = 'discord:g1:343434'; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:343434', text: quiet }]); + routing.locus = channelId; + // A conversation agent's announcement reads the same: the words follow the channel. framework.conversationAgentHomes.set('sol', channelId); slept = await sleep({ seconds: 600 }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index e3c56b64f..46917b18b 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -242,7 +242,7 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.deepEqual(asked, ['discord/discord:G1:C7'], 'asked about this channel on the server it came from'); assert.match(text, target ? /\n1\. Reply without joining — write your reply this turn prefixed with ">>#portables"; it will be delivered there\. Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message\.\n/ - : /\n1\. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; check whether discord exposes a send tool that can address it\.\n/); + : /\n1\. Reply without joining — no single-word target reaches this channel unambiguously, so a ">>" prefix can't name it; check whether discord exposes a send tool that can address it\. Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message\.\n/); assert.doesNotMatch(text, /joins the channel|decline it first/); await framework.stop(); } From dff0e031f2df234536126b30d06b021c9bcf4966 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 05:31:44 -0400 Subject: [PATCH 30/37] fix(channels): a tune-out decided while an open is on its way keeps the channel open (Greta-2425's read of af-speak6's round, room-203 #125151) I4's fix set the live flag after openChannelNow's round trip only while the decision said 'open'. A tune-out keeps the transport open, and enterTuneOut records its state whatever the transport holds, so a tune-out decided while speech's open was on its way left the server's channel open with entry.open false (Greta probed it at 5454bd29), while the same race through a reconcile left it true. openChannelNow now takes reconcile's condition: 'open' or 'tuned-out'. openForSpeech still reports 'opened' only for 'open', so a tuned-out channel gets no "[channels] Now open". - The test of an open on its way gains the tune-out case: speech's open held, the agent tunes out, the open lands: tuned-out, open at the transport, nothing reported as opened. - closedByAgent's doc block is back above closedByAgent (5454bd29 put closedByDecision between them), and closedChannelNoteFor's doc is rewrapped after its added sentence. --- src/framework.ts | 7 ++++--- src/mcpl/channel-registry.ts | 16 +++++++++------- test/channel-registry-routing.test.ts | 15 +++++++++++++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index b299d7ffa..61e6397d8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4186,9 +4186,10 @@ export class AgentFramework { * back, and a tuned-out send gets its own line (tunedOutSendNote). Every * other closed channel opens on speech, as it always did (openForSpeech), * so the agent meets the change here, where speaking used to reopen what it - * had closed. The channel is read on the server the words went to. What a closed channel still delivers beyond messages that - * address the agent is the connector's (slack-mcpl delivers ambient - * traffic by its own subscription), so the note promises only those, and + * had closed. The channel is read on the server the words went to. What + * a closed channel still delivers beyond messages that address the agent + * is the connector's (slack-mcpl delivers ambient traffic by its own + * subscription), so the note promises only those, and * what channel_open does where the server's grant lets it open channels * (channels.lifecycle). One connector state the host can't see makes the * second sentence false: a channel muted with discord-mcpl's mute_channel diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 01f3b026b..f3809e913 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2465,8 +2465,10 @@ export class ChannelRegistry { }); // A close decided while the open was on its way (the agent's channel_close // racing an open a send started) went to the server after it, so the - // channel ends closed: the live flag follows the decision. - if (this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') entry.open = true; + // channel ends closed: the live flag follows the decision. A tune-out + // keeps the transport open, as reconciliation's opens hold it. + const now = this.getDesiredState(entry.serverId, entry.descriptor.id); + if (now === 'open' || now === 'tuned-out') entry.open = true; return result; } @@ -2673,11 +2675,6 @@ export class ChannelRegistry { return matches.length === 1 ? matches[0]!.descriptor.id : undefined; } - /** - * How the agent kept this channel closed itself, if it did: 'closed' for - * its channel_close, 'declined' for its channel_decline. Its speech there - * leaves such a channel closed (openForSpeech). - */ /** Whether this channel stays closed when the agent's words go there * because the agent or a module decided to keep it closed: the agent's own * close or decline, or a module's close, which speech never opens @@ -2689,6 +2686,11 @@ export class ChannelRegistry { return current?.state === 'closed' && isCloseDecision(current.source); } + /** + * How the agent kept this channel closed itself, if it did: 'closed' for + * its channel_close, 'declined' for its channel_decline. Its speech there + * leaves such a channel closed (openForSpeech). + */ closedByAgent(serverId: string, channelId: string): 'closed' | 'declined' | undefined { const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); if (current?.state !== 'closed') return undefined; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 51a8e73c1..b67b39f3b 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -716,9 +716,9 @@ test("the latest deliberate close records itself over any closed record: the ret ['invitation-declined', 'invitation-declined', 'module-tool', 'agent-tool']); }); -test("an open still on its way when the agent closes the channel leaves it closed, whether speech or a reconcile sent it", async () => { +test("an open still on its way when the agent closes the channel leaves it closed, whether speech or a reconcile sent it; a tune-out decided meanwhile leaves it open", async () => { const { registry, holdOpens, autoOpened } = loggedRegistry([]); - await registry.handleRegister('discord', { channels: channelsOf('spoken', 'reconciled') } as never); + await registry.handleRegister('discord', { channels: channelsOf('spoken', 'reconciled', 'tuned') } as never); const agent = { kind: 'agent' as const, agentName: 'sol' }; // Speech opens 'spoken', nobody decided about it; the agent closes it while the open is on its way. @@ -744,6 +744,17 @@ test("an open still on its way when the agent closes the channel leaves it close await reregistering; assert.equal(registry.isChannelOpen('reconciled'), false); assert.equal(registry.getDesiredState('discord', 'reconciled'), 'closed'); + + // A tune-out keeps the transport open, so one decided while speech's open is on its way leaves the channel open. + held = holdOpens(); + const speakingThere = registry.routeSpeech('sol', 'answering', 'tuned'); + await held.reached; + registry.enterTuneOut('discord', 'tuned', { epochId: 'e1', cadenceSeconds: 600, backlogCap: 20, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + held.release(); + assert.deepEqual(await speakingThere, { delivered: true, channelId: 'tuned' }); + assert.equal(registry.getDesiredState('discord', 'tuned'), 'tuned-out'); + assert.equal(registry.isChannelOpen('tuned'), true, 'the transport the tune-out keeps open'); + assert.deepEqual(autoOpened, [], 'and no notice says it opened: the agent tuned it out'); }); test('a channel decided open whose open failed is closed at the transport, so the agent can decline it, and speech then leaves it closed', async () => { From 24ac21b65594986c7e34c2b624214ca39f5dd4b0 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 07:17:10 -0400 Subject: [PATCH 31/37] fix(channels): a decline of a channel decided open closes it at the server; the invitation and a send's open notice read the message's or send's server (af-speak7's round) Round on the review run af-speak7 (wf-2149, 17 issues at dff0e031; Ada-1017 on the calls, room-203 #126498): - I1: channel_decline is refused only for a channel open at the transport (af-speak6's I6), so it was accepted while an open was still on its way, or after an open whose reply was lost once the server had applied it, and it sent nothing: the server stayed open while the registry said declined. A decline of a channel whose decision is 'open' now sends channels/close, as channel_close's full path does, so the server ends closed whatever that open did (the same arrival-order reliance as channel_close). With no server or no channels.lifecycle grant nothing could have been opened, so nothing is sent. A close that throws leaves the decline recorded and puts closeError in its answer. discord-mcpl (568c293 src/server.ts:3513-3519), slack-mcpl (df218e1 src/server.ts:393-400) and zulip_mcp (0642e33 src/server.ts:742-750, src/channels.ts:458-462) answer a close of a channel they never opened without an error. openChannelNow's comment and the refusal's now say so. - I2/I9: the closed-channel invitation read whether the channel is open, and its label, on the first server with that id, while its option 1 read opts.serverId; both now read opts.serverId. The send path's "[channels] Now open" notice takes its label on the send's server. (The [delivered] and explicit-mode notes read the id's first server, which is where routeSpeech delivers, so they stay.) - I7: docs/explicit-prose-routing.md names a module's close, a tune-out and a server that can't open channels beside the agent's close or decline. - I10: closedChannelNoteFor's docstring and the sleep block's comment say speech opens a closed channel where its server can open channels. - I13: the race test gains a tune-out decided while a reconcile's open is on its way (decided open, its reply lost, so the flag reads closed). Tests: a decline while speech's open is held, after an open whose reply was lost, with a close the server refuses, of a channel nobody decided to open, and on a server without channels.lifecycle; the invitation and the send's open notice with a same-id channel open on another server. loggedRegistry's server now applies opens and closes in arrival order (serverOpen), and can lose open replies, refuse closes and change its grant. Changelog: the decline line says what it does now. --- changelog.d/agent-close-holds.fixed.md | 8 ++- docs/explicit-prose-routing.md | 17 ++--- src/framework.ts | 49 ++++++++------ src/mcpl/channel-registry.ts | 37 ++++++++-- test/channel-registry-routing.test.ts | 94 ++++++++++++++++++++++++-- test/send-into-closed-channel.test.ts | 17 +++++ 6 files changed, 179 insertions(+), 43 deletions(-) diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index 085c9d8d9..5dbf79bd3 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -26,8 +26,12 @@ reconcile after a reconnect, the channel is no longer left marked open, and a send no longer announces "[channels] Now open" after the close. The close goes to the server after the open, so the channel ends closed, and the live state now follows the decision. -- `channel_decline` of a channel decided open whose open failed, which is closed, now records the - decline. Before, it was refused as "already open". +- `channel_decline` of a channel decided open that isn't open at the transport, whose open failed + or is still on its way, now records the decline and closes the channel at the server, as + `channel_close` does, so the server ends closed whatever that open did. Before, it was refused + as "already open". discord-mcpl, slack-mcpl and zulip_mcp answer a close of a channel they never + opened without an error; a close that fails leaves the decline recorded, with `closeError` in + its answer. - `channel_close`'s description and the "[channels] Now open" notice's last sentence now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 00b55f99c..7ec76fbdc 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -55,14 +55,15 @@ agent (then notices append without waking, breaking any loop). Send tools (`send_message` etc.) and channel lifecycle work the same in explicit mode as in the other modes. A `>>` send into a closed channel opens it, with the usual `[channels]` notice, unless the agent closed or declined -that channel itself: then it publishes there and leaves it closed. Explicit mode has no `[delivered]` -receipt, so where a turn's `>>` speech stayed in a channel the agent closed or -declined, the turn ends with a `[closed channel]` line that says so, as a send -tool's answer does, unless the agent has turned that note off -(`agent_settings`' `closed_channel_note`). Speech still being delivered when -the turn ends gets the line once it has been delivered. If another turn is -under way by then, the line waits for that turn's next tool call, where that -turn is shown it, or for its end. +that channel itself, a module closed it, the agent tuned it out, or its server +can't open channels: then it publishes there and leaves the channel as it is. +Explicit mode has no `[delivered]` receipt, so where a turn's `>>` speech +stayed in a channel the agent closed or declined, the turn ends with a +`[closed channel]` line that says so, as a send tool's answer does, unless the +agent has turned that note off (`agent_settings`' `closed_channel_note`). +Speech still being delivered when the turn ends gets the line once it has been +delivered. If another turn is under way by then, the line waits for that turn's +next tool call, where that turn is shown it, or for its end. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index 61e6397d8..a30ed4fa2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4184,17 +4184,17 @@ export class AgentFramework { * leaves a channel a module closed, and a tuned-out one, as they are * (speechOpens), but there the module's own tool, or tune_out, is the way * back, and a tuned-out send gets its own line (tunedOutSendNote). Every - * other closed channel opens on speech, as it always did (openForSpeech), - * so the agent meets the change here, where speaking used to reopen what it - * had closed. The channel is read on the server the words went to. What - * a closed channel still delivers beyond messages that address the agent - * is the connector's (slack-mcpl delivers ambient traffic by its own - * subscription), so the note promises only those, and - * what channel_open does where the server's grant lets it open channels - * (channels.lifecycle). One connector state the host can't see makes the - * second sentence false: a channel muted with discord-mcpl's mute_channel - * delivers nothing, messages that address the agent included. The agent - * that muted it knows. */ + * other closed channel opens on speech where its server can open channels, + * as it always did (openForSpeech), so the agent meets the change here, + * where speaking used to reopen what it had closed. The channel is read on + * the server the words went to. What a closed channel still delivers + * beyond messages that address the agent is the connector's (slack-mcpl + * delivers ambient traffic by its own subscription), so the note promises + * only those, and what channel_open does where the server's grant lets it + * open channels (channels.lifecycle). One connector state the host can't + * see makes the second sentence false: a channel muted with discord-mcpl's + * mute_channel delivers nothing, messages that address the agent included. + * The agent that muted it knows. */ private closedChannelNoteFor(channelId: string, serverId: string | null): string | undefined { const registry = this.channelRegistry; if (!registry || !serverId || registry.isChannelOpen(channelId, serverId)) return undefined; @@ -9940,8 +9940,10 @@ export class AgentFramework { if (!this.channelRegistry) return null; if (!opts.tags?.includes('chat:addressed')) return null; if (opts.tags.includes('chat:dm')) return null; - if (this.channelRegistry.isChannelOpen(opts.channelId)) return null; - const descriptor = this.channelRegistry.getDescriptor(opts.channelId); + // Read on the server the message came from, as everything below is: two + // servers can carry a channel with the same id. + if (this.channelRegistry.isChannelOpen(opts.channelId, opts.serverId)) return null; + const descriptor = this.channelRegistry.getDescriptor(opts.channelId, opts.serverId); const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0; const label = opts.channelLabel ?? descriptor?.label; const channelLabel = label ? `#${label}` : `"${opts.channelId}"`; @@ -18457,7 +18459,7 @@ export class AgentFramework { ); this.recordChannelAutoOpenNotice( agentName, - [{ channelId: engagedChannel, label: registry.getDescriptor(engagedChannel)?.label }], + [{ channelId: engagedChannel, label: registry.getDescriptor(engagedChannel, serverId)?.label }], 'opened-by-reply', ); } else if (status === 'open-failed') { @@ -18894,14 +18896,17 @@ export class AgentFramework { destination = this.turnRouting.get(agentName)?.locus ?? null; } } - // The announcement opens a closed channel as any speech does, unless the - // agent or a module decided to keep it closed (openForSpeech): there it - // stays closed, so the announcement says the agent will still see - // messages addressed to it. That's decided from the channel as the - // announcement goes out, on the server its post goes to (routeSpeech's - // first entry with that id), since it doesn't depend on the post landing, - // which stays fire-and-forget. Those words are true in an open channel - // too, so they're the ones used when the channel's state can't be read. + // The announcement opens a closed channel as any speech does where its + // server can open channels, unless the agent or a module decided to keep + // it closed (openForSpeech): there it stays closed, so the announcement + // says the agent will still see messages addressed to it. On a server + // that can't open channels nothing opens, and what reaches the agent + // there is the server's own, so there too only that decision qualifies + // the words. That's decided from the channel as the announcement goes + // out, on the server its post goes to (routeSpeech's first entry with + // that id), since it doesn't depend on the post landing, which stays + // fire-and-forget. Those words are true in an open channel too, so + // they're the ones used when the channel's state can't be read. let staysClosed = false; if (destination && this.channelRegistry) { try { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index f3809e913..03108577f 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2463,9 +2463,9 @@ export class ChannelRegistry { address: entry.descriptor.address, ...(history ? { history } : {}), }); - // A close decided while the open was on its way (the agent's channel_close - // racing an open a send started) went to the server after it, so the - // channel ends closed: the live flag follows the decision. A tune-out + // A close decided while the open was on its way (channel_close, or a + // decline, racing an open a send started) went to the server after it, so + // the channel ends closed: the live flag follows the decision. A tune-out // keeps the transport open, as reconciliation's opens hold it. const now = this.getDesiredState(entry.serverId, entry.descriptor.id); if (now === 'open' || now === 'tuned-out') entry.open = true; @@ -2958,9 +2958,9 @@ export class ChannelRegistry { if (!entry) { return { success: false, error: resolved.error, isError: true }; } - // Refused only for a channel open at the transport: a channel decided - // open whose open failed is closed, and the invitation that comes from it - // offers the decline (option 3) as the way to reply without joining. + // Refused only for a channel open at the transport: the invitation that + // comes from a channel decided open whose open failed offers the decline + // (option 3) as the way to reply without joining. if (entry.open) { return { success: false, @@ -2969,10 +2969,32 @@ export class ChannelRegistry { }; } + // A channel decided open but not open here has an open on its way, or one + // that failed, maybe after the server applied it and its reply was lost. + // So the decline closes it at the server too, as channel_close does: the + // server applies the close after that open, and the open's reply leaves + // the flag as the decision says. + const closeAtServer = this.getDesiredState(entry.serverId, input.channelId) === 'open'; + // The lifecycle decision is authoritative even if the optional public - // acknowledgment cannot be rendered by the surface. + // acknowledgment cannot be rendered by the surface, or the close fails. this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined'); + let closeError: string | undefined; + if (closeAtServer) { + const server = this.serverRegistry.getServer(entry.serverId); + // Without the server or its lifecycle grant, no open could have been + // sent (openChannelNow and reconciliation both need them), so there is + // nothing to close. + if (server && CapabilityGrant.of(server).has('channels.lifecycle')) { + try { + await server.sendChannelsClose({ channelId: input.channelId }); + } catch (error) { + closeError = (error as Error).message; + } + } + } + let acknowledged = false; let representation: string | undefined; let acknowledgmentError: string | undefined; @@ -3018,6 +3040,7 @@ export class ChannelRegistry { acknowledged, ...(representation ? { representation } : {}), ...(acknowledgmentError ? { acknowledgmentError } : {}), + ...(closeError ? { closeError } : {}), }, }; } diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index b67b39f3b..40487e564 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -634,17 +634,29 @@ test("a module's close, made with no machine source, is the module's: speech lea * channels/open answers can be held. */ function loggedRegistry(events: unknown[]) { const openCalls: Array<{ channelId?: string }> = []; + const closeCalls: Array<{ channelId?: string }> = []; + /** What the server has open: it applies opens and closes in the order they arrive. */ + const serverOpen = new Set(); let openHold: Promise | null = null; let openReached: () => void = () => {}; + let loseOpenReplies = false; + let failCloses = false; const server = { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), sendChannelsOpen: async (params: { channelId?: string }) => { openCalls.push(params); + if (params.channelId) serverOpen.add(params.channelId); openReached(); if (openHold) await openHold; + if (loseOpenReplies) throw new Error('the reply to channels/open was lost'); + return {}; + }, + sendChannelsClose: async (params: { channelId?: string }) => { + closeCalls.push(params); + if (failCloses) throw new Error('close refused by server'); + if (params.channelId) serverOpen.delete(params.channelId); return {}; }, - sendChannelsClose: async () => ({}), sendChannelsPublish: async () => ({ delivered: true }), }; const store = { @@ -659,7 +671,11 @@ function loggedRegistry(events: unknown[]) { { store: store as never, onChannelAutoOpened: (info) => { autoOpened.push(info); } }, ); return { - registry, openCalls, autoOpened, + registry, openCalls, closeCalls, serverOpen, autoOpened, + /** The server applies each channels/open, and its reply is lost. */ + setLoseOpenReplies: (v: boolean) => { loseOpenReplies = v; }, + setFailCloses: (v: boolean) => { failCloses = v; }, + setGrant: (grant: CapabilityGrant) => { server.grant = grant; }, /** Hold every channels/open answer until the returned release; resolves `reached` at the first one. */ holdOpens: () => { let release!: () => void; @@ -717,8 +733,8 @@ test("the latest deliberate close records itself over any closed record: the ret }); test("an open still on its way when the agent closes the channel leaves it closed, whether speech or a reconcile sent it; a tune-out decided meanwhile leaves it open", async () => { - const { registry, holdOpens, autoOpened } = loggedRegistry([]); - await registry.handleRegister('discord', { channels: channelsOf('spoken', 'reconciled', 'tuned') } as never); + const { registry, holdOpens, autoOpened, setLoseOpenReplies } = loggedRegistry([]); + await registry.handleRegister('discord', { channels: channelsOf('spoken', 'reconciled', 'tuned', 'retuned') } as never); const agent = { kind: 'agent' as const, agentName: 'sol' }; // Speech opens 'spoken', nobody decided about it; the agent closes it while the open is on its way. @@ -755,6 +771,76 @@ test("an open still on its way when the agent closes the channel leaves it close assert.equal(registry.getDesiredState('discord', 'tuned'), 'tuned-out'); assert.equal(registry.isChannelOpen('tuned'), true, 'the transport the tune-out keeps open'); assert.deepEqual(autoOpened, [], 'and no notice says it opened: the agent tuned it out'); + + // The same through a reconcile: 'retuned' is decided open, but its open's reply was lost, so the + // flag says closed; a reconcile reopens it, and the agent tunes it out while that open is on its way. + setLoseOpenReplies(true); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'retuned' }, agent)).success, false); + setLoseOpenReplies(false); + assert.equal(registry.isChannelOpen('retuned'), false); + held = holdOpens(); + const reconcilingTuned = registry.handleRegister('discord', { channels: channelsOf('retuned') } as never); + await held.reached; + registry.enterTuneOut('discord', 'retuned', { epochId: 'e2', cadenceSeconds: 600, backlogCap: 20, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + held.release(); + await reconcilingTuned; + assert.equal(registry.getDesiredState('discord', 'retuned'), 'tuned-out'); + assert.equal(registry.isChannelOpen('retuned'), true, "the reconcile's open lands under a tune-out, which keeps the transport open"); +}); + +test('a decline decided while an open is on its way, or after one whose reply was lost, closes the channel at the server too', async () => { + const events: Array<{ kind?: string; channelId?: string }> = []; + const { registry, holdOpens, autoOpened, closeCalls, serverOpen, setLoseOpenReplies, setFailCloses, setGrant } = loggedRegistry(events); + await registry.handleRegister('discord', { channels: channelsOf('spoken', 'lost', 'refusing', 'plain', 'ungranted') } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + const closesBefore = closeCalls.length; // the first registration's reconcile closes each channel + + // Speech opens 'spoken', which nobody decided about; the agent declines it while the open is on its way. + const held = holdOpens(); + const speaking = registry.routeSpeech('sol', 'answering', 'spoken'); + await held.reached; + const declined = await registry.handleChannelToolCall('channel_decline', { channelId: 'spoken', messageId: 'm1' }, agent); + assert.equal(declined.success, true, declined.error); + held.release(); + assert.deepEqual(await speaking, { delivered: true, channelId: 'spoken' }); + assert.equal(serverOpen.has('spoken'), false, 'the server applied the open, then the close'); + assert.equal(registry.isChannelOpen('spoken'), false); + assert.equal(registry.closedByAgent('discord', 'spoken'), 'declined'); + assert.deepEqual(autoOpened, [], 'and nothing reports the channel as opened'); + + // The agent's open of 'lost' reached the server, and its reply was lost. + setLoseOpenReplies(true); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'lost' }, agent)).success, false); + setLoseOpenReplies(false); + assert.equal(serverOpen.has('lost'), true); + assert.equal(registry.isChannelOpen('lost'), false); + const declinedLost = await registry.handleChannelToolCall('channel_decline', { channelId: 'lost', messageId: 'm2' }, agent); + assert.equal(declinedLost.success, true, declinedLost.error); + assert.equal(serverOpen.has('lost'), false, 'the decline closed it at the server'); + assert.equal((declinedLost.data as { closeError?: string }).closeError, undefined); + + // A close the server refuses leaves the decline recorded, and the decline's answer says so. + setLoseOpenReplies(true); + await registry.handleChannelToolCall('channel_open', { channelId: 'refusing' }, agent); + setLoseOpenReplies(false); + setFailCloses(true); + const declinedRefusing = await registry.handleChannelToolCall('channel_decline', { channelId: 'refusing', messageId: 'm3' }, agent); + setFailCloses(false); + assert.equal(declinedRefusing.success, true, declinedRefusing.error); + assert.match(String((declinedRefusing.data as { closeError?: string }).closeError), /close refused by server/); + assert.equal(registry.closedByAgent('discord', 'refusing'), 'declined'); + assert.ok(events.some((e) => e.kind === 'invitation-declined' && e.channelId === 'refusing')); + + // A decline of a channel nobody decided to open sends nothing to the server. + const declinedPlain = await registry.handleChannelToolCall('channel_decline', { channelId: 'plain', messageId: 'm4' }, agent); + assert.equal(declinedPlain.success, true, declinedPlain.error); + // Nor does one on a server whose grant lacks channels.lifecycle, where no open was ever sent. + setGrant(new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), [])); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'ungranted' }, agent)).success, false); + assert.equal(registry.getDesiredState('discord', 'ungranted'), 'open'); + const declinedUngranted = await registry.handleChannelToolCall('channel_decline', { channelId: 'ungranted', messageId: 'm5' }, agent); + assert.equal(declinedUngranted.success, true, declinedUngranted.error); + assert.deepEqual(closeCalls.slice(closesBefore).map((c) => c.channelId), ['spoken', 'lost', 'refusing']); }); test('a channel decided open whose open failed is closed at the transport, so the agent can decline it, and speech then leaves it closed', async () => { diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index d35a773f9..1ba8e049e 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -320,6 +320,23 @@ test("a send's note reads the channel on the server the words went to, not one o assert.ok(data.endsWith(UPKEEP_NOTE), data); }); +test("an invitation, and the notice a send's open gives, read the channel on the server the message or the send came from, not one on another server with the same id", async () => { + const { registry, framework, windowMessages, reply } = await harness({ sharedIdFirst: true }); + // discord2's channel with this id is open, and it's the one a lookup by bare id finds. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId, serverId: 'discord2' }, origin)).success, true); + // A mention arrives from discord's #upkeep, which is closed. + const invitation = framework.buildClosedChannelInvitation({ + serverId: 'discord', channelId, messageId: 'm1', authorName: 'Ann', tags: ['chat:addressed'], + }); + assert.ok(invitation, "discord's channel is closed, so the mention comes with an invitation"); + const invited = String(invitation.block.text); + assert.ok(invited.includes('upkeep') && !invited.includes('elsewhere'), invited); + // A send into discord's #upkeep, which nobody decided about, opens it, and the notice names it. + assert.equal((await reply()).success, true); + assert.equal(registry.isChannelOpen(channelId, 'discord'), true); + assert.ok(windowMessages.at(-1)?.startsWith(`[channels] Now open: #upkeep (${channelId})`), windowMessages.at(-1)); +}); + test('a send into an open channel, or into a channel a module closed, carries no closed-channel note', async () => { { const { registry, reply } = await harness(); From 3e636b81de2b9352717c1d529b52acd7cbf1ba31 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 07:23:34 -0400 Subject: [PATCH 32/37] fix(channels): a decline of a tuned-out channel not open at the transport closes it at the server too (Greta-2425's read of af-speak7's round, room-203 #126613) closeAtServer was the decision 'open' only, but a 'tuned-out' decision holds the transport open too, as openChannelNow already treats it. So a decline decided while speech's open was on its way, after the agent tuned the channel out, sent no close: the open then landed, and the server kept the channel open while the host recorded it closed (Greta's probe at 24ac21b6). The decline now closes it at the server for 'open' or 'tuned-out'. The decline test gains that case; a new removal piece puts back 'open' alone. The comment, the changelog and the description name the tune-out. --- changelog.d/agent-close-holds.fixed.md | 11 ++++++----- src/mcpl/channel-registry.ts | 14 ++++++++------ test/channel-registry-routing.test.ts | 17 +++++++++++++++-- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index 5dbf79bd3..e38de961f 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -27,11 +27,12 @@ announces "[channels] Now open" after the close. The close goes to the server after the open, so the channel ends closed, and the live state now follows the decision. - `channel_decline` of a channel decided open that isn't open at the transport, whose open failed - or is still on its way, now records the decline and closes the channel at the server, as - `channel_close` does, so the server ends closed whatever that open did. Before, it was refused - as "already open". discord-mcpl, slack-mcpl and zulip_mcp answer a close of a channel they never - opened without an error; a close that fails leaves the decline recorded, with `closeError` in - its answer. + or is still on its way, now records the decline, where it was refused as "already open". A + decline of such a channel, or of a tuned-out one that isn't open at the transport, also closes + the channel at the server, as `channel_close` does, so the server ends closed whatever an open + on its way, or one whose reply was lost, did. discord-mcpl, slack-mcpl and zulip_mcp answer a + close of a channel they never opened without an error; a close that fails leaves the decline + recorded, with `closeError` in its answer. - `channel_close`'s description and the "[channels] Now open" notice's last sentence now say that a channel the agent closes stays closed until it opens it with `channel_open`, and that neither speaking there nor policy reopens it. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 03108577f..e20618fcd 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2969,12 +2969,14 @@ export class ChannelRegistry { }; } - // A channel decided open but not open here has an open on its way, or one - // that failed, maybe after the server applied it and its reply was lost. - // So the decline closes it at the server too, as channel_close does: the - // server applies the close after that open, and the open's reply leaves - // the flag as the decision says. - const closeAtServer = this.getDesiredState(entry.serverId, input.channelId) === 'open'; + // A channel whose decision holds the transport open (open, or tuned out), + // but that isn't open here, has an open on its way, or one that failed, + // maybe after the server applied it and its reply was lost. So the + // decline closes it at the server too, as channel_close does: the server + // applies the close after that open, and the open's reply leaves the flag + // as the decision says. + const held = this.getDesiredState(entry.serverId, input.channelId); + const closeAtServer = held === 'open' || held === 'tuned-out'; // The lifecycle decision is authoritative even if the optional public // acknowledgment cannot be rendered by the surface, or the close fails. diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 40487e564..cbabdf30c 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -791,7 +791,7 @@ test("an open still on its way when the agent closes the channel leaves it close test('a decline decided while an open is on its way, or after one whose reply was lost, closes the channel at the server too', async () => { const events: Array<{ kind?: string; channelId?: string }> = []; const { registry, holdOpens, autoOpened, closeCalls, serverOpen, setLoseOpenReplies, setFailCloses, setGrant } = loggedRegistry(events); - await registry.handleRegister('discord', { channels: channelsOf('spoken', 'lost', 'refusing', 'plain', 'ungranted') } as never); + await registry.handleRegister('discord', { channels: channelsOf('spoken', 'tuned', 'lost', 'refusing', 'plain', 'ungranted') } as never); const agent = { kind: 'agent' as const, agentName: 'sol' }; const closesBefore = closeCalls.length; // the first registration's reconcile closes each channel @@ -808,6 +808,19 @@ test('a decline decided while an open is on its way, or after one whose reply wa assert.equal(registry.closedByAgent('discord', 'spoken'), 'declined'); assert.deepEqual(autoOpened, [], 'and nothing reports the channel as opened'); + // The same after the agent tuned 'tuned' out while speech's open was on its way: a tune-out + // holds the transport open too. + const heldTuned = holdOpens(); + const speakingTuned = registry.routeSpeech('sol', 'answering', 'tuned'); + await heldTuned.reached; + registry.enterTuneOut('discord', 'tuned', { epochId: 'e3', cadenceSeconds: 600, backlogCap: 20, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + const declinedTuned = await registry.handleChannelToolCall('channel_decline', { channelId: 'tuned', messageId: 'm6' }, agent); + assert.equal(declinedTuned.success, true, declinedTuned.error); + heldTuned.release(); + assert.deepEqual(await speakingTuned, { delivered: true, channelId: 'tuned' }); + assert.equal(serverOpen.has('tuned'), false, 'the server applied the open, then the close'); + assert.equal(registry.isChannelOpen('tuned'), false); + // The agent's open of 'lost' reached the server, and its reply was lost. setLoseOpenReplies(true); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'lost' }, agent)).success, false); @@ -840,7 +853,7 @@ test('a decline decided while an open is on its way, or after one whose reply wa assert.equal(registry.getDesiredState('discord', 'ungranted'), 'open'); const declinedUngranted = await registry.handleChannelToolCall('channel_decline', { channelId: 'ungranted', messageId: 'm5' }, agent); assert.equal(declinedUngranted.success, true, declinedUngranted.error); - assert.deepEqual(closeCalls.slice(closesBefore).map((c) => c.channelId), ['spoken', 'lost', 'refusing']); + assert.deepEqual(closeCalls.slice(closesBefore).map((c) => c.channelId), ['spoken', 'tuned', 'lost', 'refusing']); }); test('a channel decided open whose open failed is closed at the transport, so the agent can decline it, and speech then leaves it closed', async () => { From b554bce4ce5c408de75ca7d9dcb5f569edcd0978 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 07:28:04 -0400 Subject: [PATCH 33/37] test(channels): a module's close with an unknown source is refused on a channel that's already closed too The description and the breaking changelog say that, since the already-closed path now records the close's source, a module's unknown machine source is checked before it and refused there too, where main returned 'already closed' (main channel-registry.ts:2749-2753 before :2770). No test pinned that order; this one closes a channel as the agent, then has a module close it naming 'gremlin-module': refused, and the agent's close stays on record. A removal piece puts back main's order. --- test/channel-registry-routing.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index cbabdf30c..c0d854c77 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -587,6 +587,21 @@ test("channel_close's description says the close stands against speech and polic /It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it\.$/); }); +test("a module's close naming a source outside the machine vocabulary is refused on a channel that's already closed too", async () => { + const { registry } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { + added: [{ id: 'shut', type: 'discord', label: '#shut', direction: 'bidirectional' }], + } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'shut' }, agent)).success, true); + // main answered "already closed" here, before looking at the source. + const refused = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'shut', source: 'gremlin-module' }, { kind: 'module', moduleName: 'gremlin' }); + assert.equal(refused.success, false); + assert.match(refused.error ?? '', /Unknown machine close source/); + assert.equal(registry.closedByAgent('discord', 'shut'), 'closed', "the agent's close stays on record"); +}); + test("a module's close, made with no machine source, is the module's: speech leaves it closed, and it isn't the agent's", async () => { const { registry, openCalls, publishCalls } = makeRegistry({ delivered: true }); await registry.handleChanged('discord', { From 026ffb3f7727a07d09b51732884235599fcc0480 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 17 Oct 2026 13:13:20 -0400 Subject: [PATCH 34/37] fix(mcpl): a script's send keeps the server's receipt; its note goes to the agent's window Greptile's second review of #330 found that a send's closed-channel note, or its tune-out line, was appended to the send's result as a second text block, and dispatchMcplToolCall joins a text-only result into one string, which is also what a code_execution script receives (handleScriptToolCall). discord-mcpl's receipt is JSON ({ messageId, channelId, ... }), so a script that decoded it to read messageId failed after the message had posted. On main a script got the server's receipt alone. Ada-1017's call (room-203 #131517): a script's send (a pytc- call, the id awaitSiblingToolCalls already reads as script-inner) gets the server's receipt alone, as on main, and the agent is told in its window instead, by a channel-notice through addMessage, the path "[channels] Now open" takes, addressed to the agent whose script sent (forAgent), as the explicit-mode note is, so its turn's deferral applies and another agent isn't told: "[closed channel] Your script's reply_message: ..." or "[tuned out] Your script's send_dm: ...", one per call, with the same sentence for turning it off. A window that can't take the notice logs why and leaves the receipt as it was. The model's own sends keep the note in their answer, as before. send-into-closed-channel.test.ts gains a test: a script's reply into a channel the agent closed, and a script's send_dm into one it tuned out, each parse as the server's receipt and leave one notice, addressed to the sending agent; the model's own send still carries the note; a window that throws leaves the receipt and logs; and the setting turns the notice off. The breaking fragment says what a script's send gets. --- changelog.d/agent-close-holds.breaking.md | 5 +- src/framework.ts | 33 +++++++--- test/send-into-closed-channel.test.ts | 74 +++++++++++++++++++++-- 3 files changed, 97 insertions(+), 15 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 974b73f51..363e49a68 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -24,7 +24,10 @@ channel the agent tuned out, which ended the tune-out before, now leaves it in place, and its answer says so: "… stays tuned out: your words there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out ends. tune_out with channelId … and mode - "cancel" resumes normal attention there." The same setting turns that line off. + "cancel" resumes normal attention there." The same setting turns that line off. A send a + `code_execution` script makes keeps the server's receipt alone, as before, since the script + parses it, and the agent gets the note or the tune-out line in its window instead, as a notice + the way "[channels] Now open" comes. - **What the invitation says:** the closed-channel invitation's first option now says what replying does there. In a channel the agent closed or declined itself, or a module closed, it reads as before: "Reply without joining …", with the note that follow-ups that don't address diff --git a/src/framework.ts b/src/framework.ts index a30ed4fa2..907065b6d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4221,7 +4221,8 @@ export class AgentFramework { * tune-out; now it stands, so the agent that sent there expecting answers * learns they still go to its subconscious. Only the send tools ended a * tune-out (routed speech never did while the transport was open), so only - * their answers carry this, send_dm's included: a tune-out diverts a DM + * their answers carry this, send_dm's included, or for a code_execution + * script's send a notice in the agent's window: a tune-out diverts a DM * too. The same setting turns it off, a conversation agent gets none, and * a failure while working it out is logged and adds nothing, as for the * closed-channel note. */ @@ -18437,15 +18438,31 @@ export class AgentFramework { if (engagedChannel) { sendingTurn?.engaged.add(engagedChannel); // Where the agent's words stayed in a channel it closed or - // declined itself, or tuned out, the send's answer says so. + // declined itself, or tuned out, the send's answer says so. A + // code_execution script's send (`pytc-…`) keeps the server's + // receipt alone, as before, since the script parses it, and the + // agent whose script sent is told in its own window instead, by + // the path "Now open" takes. const [closedNote] = this.closedChannelNotes(agentName, [engagedChannel], serverId); const tunedOutNote = this.tunedOutSendNote(agentName, engagedChannel, serverId); - const note = closedNote ? `[closed channel] ${closedNote}` : tunedOutNote ? `[tuned out] ${tunedOutNote}` : undefined; - if (note) { - result.content = [ - ...(result.content ?? []), - { type: 'text', text: `${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, - ]; + const tag = closedNote ? '[closed channel]' : tunedOutNote ? '[tuned out]' : undefined; + const body = closedNote ?? tunedOutNote; + if (tag && body) { + if (call.id.startsWith('pytc-')) { + try { + this.addMessage('user', [{ + type: 'text', + text: `${tag} Your script's ${toolName}: ${body} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}`, + }], { system: true, kind: 'channel-notice' }, { forAgent: agentName }); + } catch (err) { + console.error(`[closed-channel-note] for ${agentName}'s script failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + result.content = [ + ...(result.content ?? []), + { type: 'text', text: `${tag} ${body} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }, + ]; + } } // Fire-and-forget: the send already succeeded; the open must not // delay or fail the tool result. diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 1ba8e049e..f78a98864 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -9,7 +9,9 @@ * re-pin. Where the words land in a channel the agent closed or declined * itself, the send's answer, or the turn's delivery line for routed speech, * says the channel stays closed, what still reaches the agent from it, and - * how to open it, until the agent turns that note off with agent_settings. + * how to open it, until the agent turns that note off with agent_settings. A + * code_execution script's send keeps the server's receipt, which the script + * parses, and the agent is told by a notice in its window instead. */ import { test } from 'node:test'; @@ -121,19 +123,23 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { // Conversation agents by their home channel; sol isn't one. framework.conversationAgentHomes = new Map(); const windowMessages: string[] = []; - framework.addMessage = (_role: string, content: Array<{ text?: string }>) => { + /** Whose window each of those was addressed to: addMessage's forAgent, or + * undefined for the primary agent's. */ + const windowFor: Array = []; + framework.addMessage = (_role: string, content: Array<{ text?: string }>, _metadata?: unknown, opts?: { forAgent?: string }) => { windowMessages.push(content.map((b) => b.text ?? '').join('')); + windowFor.push(opts?.forAgent); return 'msg'; }; /** Dispatch reply_message by the raw id; resolve with its result and the * turn's engaged channels as they stood when the result was queued (the * mid-turn re-pin reads them when that event is processed). */ - async function reply(tool = 'reply_message'): Promise<{ success: boolean; engagedAtResult: string[]; data: string; error: string }> { + async function reply(tool = 'reply_message', callId = 'call-reply'): Promise<{ success: boolean; engagedAtResult: string[]; data: string; error: string }> { const out = await new Promise<{ success: boolean; engagedAtResult: string[]; data: string; error: string }>((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown; error?: string } }) => { - if (event.type === 'tool-result' && event.callId === 'call-reply') { + if (event.type === 'tool-result' && event.callId === callId) { clearTimeout(timeout); resolve({ success: event.result.success, @@ -145,7 +151,7 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { }; framework.dispatchMcplToolCall( 'sol', - { id: 'call-reply', name: `discord--${tool}`, input: { channelId: sentTo, messageId: 'm1', content: 'answering, without moving in' } }, + { id: callId, name: `discord--${tool}`, input: { channelId: sentTo, messageId: 'm1', content: 'answering, without moving in' } }, 'discord', 'discord', ); @@ -157,7 +163,7 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { } return { - registry, framework, opens, windowMessages, reply, published, + registry, framework, opens, windowMessages, windowFor, reply, published, setRefuseOpens: (v: boolean) => { refuseOpens = v; }, setHold: (p: Promise | null) => { hold = p; @@ -494,6 +500,62 @@ test('a send into a tuned-out channel leaves the tune-out in place, and its answ assert.ok(!(await reply()).data.includes('[tuned out]')); }); +test("a code_execution script's send into a channel the agent closed or tuned out keeps the server's receipt, and the agent is told in its window", async () => { + const { registry, framework, windowMessages, windowFor, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + const receipt = { messageId: 'm9', channelId: '444555' }; + + // A script parses the receipt, so it's the server's own, as before. + const scripted = await reply('reply_message', 'pytc-1'); + assert.equal(scripted.success, true); + assert.deepEqual(JSON.parse(scripted.data), receipt); + const closedBody = UPKEEP_NOTE.slice('[closed channel] '.length); + assert.deepEqual(windowMessages, [`[closed channel] Your script's reply_message: ${closedBody}`]); + assert.deepEqual(windowFor, ['sol'], "it's addressed to the agent whose script sent, not to the primary's window by default"); + // The model's own send keeps the note in its answer, and adds nothing to the window. + assert.ok((await reply()).data.endsWith(`\n${UPKEEP_NOTE}`)); + assert.equal(windowMessages.length, 1); + + // A tune-out's line goes the same way. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + registry.enterTuneOut('discord', channelId, { + epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, + }, 'agent-tool'); + windowMessages.length = 0; + windowFor.length = 0; + assert.deepEqual(JSON.parse((await reply('send_dm', 'pytc-2')).data), receipt); + assert.equal(windowMessages.length, 1); + assert.deepEqual(windowFor, ['sol']); + assert.ok(windowMessages[0]!.startsWith(`[tuned out] Your script's send_dm: #upkeep (${channelId}) stays tuned out: `), windowMessages[0]); + assert.ok(windowMessages[0]!.endsWith('To stop this note, set closed_channel_note to false with agent_settings.')); + + // A window that can't take the notice leaves the receipt as it was, and the operator's log says why. + const addMessage = framework.addMessage; + framework.addMessage = () => { throw new Error('window unavailable'); }; + const logged: string[] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => { + const line = args.map(String).join(' '); + if (line.startsWith('[closed-channel-note]')) logged.push(line); + }; + try { + const sent = await reply('send_dm', 'pytc-3'); + assert.equal(sent.success, true); + assert.deepEqual(JSON.parse(sent.data), receipt); + assert.deepEqual(logged, ["[closed-channel-note] for sol's script failed: window unavailable"]); + } finally { + console.error = consoleError; + framework.addMessage = addMessage; + } + + // The setting turns the notice off too. + framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); + windowMessages.length = 0; + assert.deepEqual(JSON.parse((await reply('reply_message', 'pytc-4')).data), receipt); + assert.deepEqual(windowMessages, []); +}); + test('a reply_message still in flight when the agent closes its channel leaves it closed', async () => { const { registry, opens, reply, setHold, sendReached } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); From c10d89eb394cd34f3f8272829932b67662ef3da8 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sun, 18 Oct 2026 02:07:15 -0400 Subject: [PATCH 35/37] docs(changelog): #330's fragments say what the invitation says now that #429 is in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #330 carries #429's text (merged at d4b933c6). #429's fragment ships in 0.22.3, before #330 can land, so #330's fragments describe the change from 0.22.3's words (Ada-1017, room-203 #139047): - The breaking fragment's invitation bullet: in a channel the agent closed or declined itself, or a module closed, a reply no longer opens the channel, and option 1 says "… it will be delivered there, and the channel stays closed" where 0.22.3 says "… which opens the channel"; elsewhere it adds how to reply without opening it. What else reaches the agent from a channel left closed is its server's to say (#406), with discord-mcpl's case and anima-research/discord-mcpl#97. - The mentions-mode line: the invitation there says the channel stays closed. - The fixed fragment's no-target line: it no longer says to open the channel to reply (0.22.3's "Open it (option 2) to reply"). #429's own fragment is left as it is: the cut folds and deletes it. Changelog fragments only. --- changelog.d/agent-close-holds.breaking.md | 19 +++++++++++-------- changelog.d/agent-close-holds.fixed.md | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index 363e49a68..ae2c0cf10 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -29,13 +29,16 @@ parses it, and the agent gets the note or the tune-out line in its window instead, as a notice the way "[channels] Now open" comes. - **What the invitation says:** the closed-channel invitation's first option now says what - replying does there. In a channel the agent closed or declined itself, or a module closed, it - reads as before: "Reply without joining …", with the note that follow-ups that don't address - the agent won't reach it. In any other closed channel, where a reply opens the channel, it reads "1. Reply — - … it will be delivered there, and replying joins the channel, as option 2 does. To reply - without joining, decline it first (option 3): a channel you've declined stays closed when you - speak there." Before, it offered "Reply without joining" there too, though the reply opened - the channel. + replying does there. In a channel the agent closed or declined itself, or a module closed, a + reply no longer opens the channel, and the option says so: "1. Reply — … it will be delivered + there, and the channel stays closed." Before, it said "… which opens the channel" there too. + It says the same on a server whose grant lacks `channels.lifecycle`, where 0.22.3 said a reply + opens the channel though nothing there could. In any other closed channel, where a reply still + opens the channel, it adds how to reply without opening it: "To reply without opening it, + decline it first (option 3): a channel you've declined stays closed when you speak there." + What else reaches the agent from a channel left closed is its server's to say (#406): on + discord-mcpl, the channel's later messages that don't address the agent don't arrive as + they're posted, and anima-research/discord-mcpl#97 has discord-mcpl say so on its push. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the @@ -57,7 +60,7 @@ where it was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, but gives no note, since the module owns the way back. In connectome-host that's the `mentions` channel mode: a reply to a mention in a channel in that mode stays a reply, and the invitation - there offers "Reply without joining". Policy doesn't lift such a close, and a machine's close + there says the channel stays closed. Policy doesn't lift such a close, and a machine's close doesn't replace it. Of the agent's close and a module's, the later one is the one on record. A module's close is never refused as a machine's close of an explicit open; only one naming a machine source is. A module's `channel_close` naming an unknown machine source is now refused on diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index e38de961f..d53f620e6 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -38,5 +38,5 @@ speaking there nor policy reopens it. - The closed-channel invitation is no longer added to a direct message (`chat:dm`), all of which reaches the agent whether the DM is open or not. Where no single-word target reaches a channel, - it no longer says that replying needs a join: it suggests checking whether the server has a + it no longer says to open the channel to reply: it suggests checking whether the server has a send tool that can address the channel. From fceef446aead1e98c84dbfc284bc40406d8fd62a Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 24 Oct 2026 04:51:13 -0400 Subject: [PATCH 36/37] Agent close holds: no give-way where the target is in doubt; judge the notes per reader A review of the merge of main found these, each answered here. Words that give way to the agent's close: - A wait or an open that fails no longer gives way to a close the agent decided meanwhile where the failure leaves in doubt which channel the server reaches by that id: an open answered for another channel ('substituted', also when an overtaking close wraps it as its cause), or convergence that never settled the channel's address ('converge'). The words could otherwise be published by an id the server had just shown it resolves elsewhere. refusalGivesWay(err, now) is the one check, at publish()'s three checks and openIfClosedForSend's catch. - lifecycleWithheld(serverId) is the one reading of a server this host can't direct (up, without channels.lifecycle), for speechLeavesAsItStands and the note. The notes, judged for their reader as main's opened notices are: - The closed-channel note says that messages addressing the agent still reach it only where they land in its window (residentHoldsTraffic), not to an ephemeral run, whose window is its own; offers channel_open only where the agent's allowedTools hold it and the server isn't up without channels.lifecycle, so a note written while the server reconnects keeps it; and says how to turn the note off only where agent_settings is allowed. The two sentences after the first are now sentences of their own. - The tune-out line's way back is tune_out's own refusals' sentence (tuneOutEnd), which names the channel by the string that picks it alone, its server-qualified name where another server's channel shares its id, where the raw id could cancel the wrong tune-out or be ambiguous. It's left out where tune_out isn't allowed. - closedByAgent answers nothing for a channel no longer registered, so a removed channel gets no note promising channel_open. - The sleep words follow speechLeavesClosed (not open at the transport, and speech leaves it as it stands), which replaces closedByDecision and covers a closed channel on a server that's up without channels.lifecycle. - The invitation leaves out "decline it first" in a tuned-out channel, which channel_decline refuses. Texts: the comments on closeStands (it named engagesDecidedClosed, which is gone), setDesiredState (a machine's close never replaces a closed record), publish(), SEND_ENGAGEMENT_TOOLS, the invitation, the sleep words and sendChannelsOpen now say what the code does; both fragments name the module's close, the target-in-doubt exception, the per-reader note, the tune-out sentence, the sleep words' cases, the decline's tuned-out refusal and the new methods. A routing test's section comment and a test title that named a case it never ran are corrected. Tests: a substituted open with the agent's close made while it was out, for speech and for a send; a close whose convergence ran out while words waited on it; the agent's close while the last check waits on a retarget made during the opening's notice; the note's per-reader sentences, a reconnecting server, an answer with an image block and one with no words, a send-target lookup that throws; the tune-out sentence with a shared id and without tune_out; the invitation in a tuned-out channel; a removed channel's closedByAgent; explicit mode's note once for two envelopes into one channel; the sleep words on a server without channels.lifecycle. trunk-channel-routing's invitation stubs answer getDesiredState. Co-Authored-By: Claude Opus 5.5 --- changelog.d/agent-close-holds.breaking.md | 103 +++++++------- changelog.d/agent-close-holds.fixed.md | 22 +-- src/framework.ts | 105 ++++++++------ src/mcpl/channel-registry.ts | 120 +++++++++++----- src/mcpl/server-connection.ts | 2 +- test/channel-lifecycle-order.test.ts | 83 ++++++++++++ test/channel-registry-routing.test.ts | 26 +++- test/explicit-prose-routing.test.ts | 17 ++- test/send-into-closed-channel.test.ts | 158 ++++++++++++++++++++-- test/trunk-channel-routing.test.ts | 9 ++ 10 files changed, 495 insertions(+), 150 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index e790b8b88..d9eb8dbf4 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -4,11 +4,13 @@ declined publishes there and leaves the channel closed. Before, it opened the channel, recorded that open over the agent's close, and announced "[channels] Now open: … your own message into the closed channel opened it." That holds where the agent's close failed at the server and left the - channel's transport open too: the words go out there, and the close stays the decision on - record, where speech used to reopen the channel. Words that wait on a channel's lifecycle work, - or on an open they started, go into the channel as it stands once the agent closes or declines - it meanwhile, or a module closes it, where they were refused with "a decision to close the - channel came before these words went out". Speech into any other closed channel, one nobody + channel's transport open too: the words go out there, and the close stays the decision on record, + where speech used to reopen the channel. Words that wait on a channel's lifecycle work, or on an + open they started, go into the channel as it stands once the agent closes or declines it + meanwhile, or a module closes it, where they were refused with "a decision to close the channel + came before these words went out". They're still refused where the failure leaves in doubt which + channel the server reaches by that id: an open answered for another channel, or a channel whose + address kept changing until the tries ran out. Speech into any other closed channel, one nobody decided about (such as a DM that woke the agent) or one a machine closed, opens it as before, with that notice, and a close a machine decides while words wait still refuses them. - **Who needs to act:** an agent that closed a channel and wants its ongoing traffic again opens @@ -17,32 +19,40 @@ - **What residents read:** where an agent's words land in a channel it closed or declined, the send tool's answer, for routed speech the turn's `[delivered]` line, or in `explicit` routing mode, which has no such line, a line of its own at the turn's end, says that the channel stays - closed, that messages addressing the agent still reach it, and that `channel_open` opens it. - The last clause is left out on a server whose grant lacks `channels.lifecycle`, where no - channel can open. In `explicit` mode, speech still being delivered when a tool ends the turn - gets the line once it's delivered, and a line that comes while another turn is under way + closed, and, as each holds for the agent reading it, that messages addressing it still reach + it, that `channel_open` opens it, and how to turn the note off. The first is left out for an + agent whose window isn't where those messages land, such as an ephemeral run's; `channel_open` + where the agent's `allowedTools` leave it out, or on a server that's up but whose grant lacks + `channels.lifecycle`, where no channel can open; and the last where `allowedTools` leave + `agent_settings` out. In `explicit` mode, speech still being delivered when a tool ends the + turn gets the line once it's delivered, and a line that comes while another turn is under way waits for that turn's next tool call, where that turn is shown it, or for its end. The agent turns the note off for good with `agent_settings`' new `closed_channel_note`, which is on by default. A conversation agent gets none: `channel_open` is refused to it, and its settings end - with its conversation. In such a channel, and in one a module closed, the `sleep` tool's - default announcement says "I'll still see messages addressed to me, but won't respond until I - wake." A send tool's call into a channel the agent tuned out keeps the tune-out, as it has - since 0.23.0, and its answer now says so: "… stays tuned out: your words there don't end the - tune-out, so its traffic still goes to your subconscious until the tune-out ends. tune_out with - channelId … and mode "cancel" resumes normal attention there." The same setting turns that line - off. A send a `code_execution` script makes keeps the server's receipt alone, since the script - parses it, and the agent gets the note or the tune-out line in its window instead, as a notice. + with its conversation. In a channel the agent's words leave closed, one it closed or declined, + one a module closed, or a closed one on a server that's up without `channels.lifecycle`, the + `sleep` tool's default announcement says "I'll still see messages addressed to me, but won't + respond until I wake." A send tool's call into a channel the agent tuned out keeps the + tune-out, as it has since 0.23.0, and its answer now says so: "… stays tuned out: your words + there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out + ends. Call tune_out with channelId … and mode "cancel" for it to resume normal attention; + cancelling delivers the diverted backlog." The last sentence is `tune_out`'s own refusals' one, + naming the channel as they do, for an agent whose `allowedTools` hold `tune_out`. The same + setting turns that line off. A send a `code_execution` script makes keeps the server's receipt + alone, since the script parses it, and the agent gets the note or the tune-out line in its + window instead, as a notice. - **What the invitation says:** the closed-channel invitation's first option now says what replying does there. In a channel the agent closed or declined itself, or a module closed, a reply no longer opens the channel, and the option says so: "1. Reply — … it will be delivered - there, and the channel stays closed." Before, it said "… which opens the channel" there too. - It says the same on a server whose grant lacks `channels.lifecycle`, where nothing opens. In - any other closed channel, where a reply still opens the channel, it adds how to reply without + there, and the channel stays closed." Before, it said "… which opens the channel" there too. It + says the same on a server whose grant lacks `channels.lifecycle`, where nothing opens. In any + other closed channel, where a reply still opens the channel, it adds how to reply without opening it: "To reply without opening it, decline it first (option 3): a channel you've - declined stays closed when you speak there." What else reaches the agent from a channel left - closed is its server's to say (#406): on discord-mcpl, the channel's later messages that don't - address the agent don't arrive as they're posted, and anima-research/discord-mcpl#97 has - discord-mcpl say so on its push. + declined stays closed when you speak there." It leaves that out in a tuned-out channel, which + `channel_decline` refuses. What else reaches the agent from a channel left closed is its + server's to say (#406): on discord-mcpl, the channel's later messages that don't address the + agent don't arrive as they're posted, and anima-research/discord-mcpl#97 has discord-mcpl say + so on its push. - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the @@ -51,24 +61,23 @@ set with `mute_channel`, so a reply that opens a muted channel nobody decided about, or one a machine closed, unmutes it, as before; a channel the agent closed keeps its mute. On zulip_mcp, an answer's topic comes from the last message delivered as `channels/incoming`, and a closed - stream's mentions are pushed instead, so in a stream the agent closed, every answer goes to - the topic of the stream's last message from before the close, where a reply used to reopen the + stream's mentions are pushed instead, so in a stream the agent closed, every answer goes to the + topic of the stream's last message from before the close, where a reply used to reopen the stream. - **What hosts notice:** on discord-mcpl, a channel the agent closed keeps delivering the - messages that address the agent as push events, as any closed channel's are, where a reply - used to reopen it and switch them to `channels/incoming`. Host code that acts on - `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. Nor does - a turn with no trigger, such as a heartbeat, come to speak there: where it speaks moves only - with `channels/incoming` messages. -- **Hosts with modules that close channels:** a module's `channel_close` through its own tool - call (`ctx.callTool`) without a machine source is now recorded with the source `module-tool`, - where it was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, - but gives no note, since the module owns the way back. In connectome-host that's the `mentions` - channel mode: a reply to a mention in a channel in that mode stays a reply, and the invitation - there says the channel stays closed. Policy doesn't lift such a close, and a machine's close - doesn't replace it. Of the agent's close and a module's, the later one is the one on record. A - module's close is never refused as a machine's close of an explicit open; only one naming a - machine source is. + messages that address the agent as push events, as any closed channel's are, where a reply used + to reopen it and switch them to `channels/incoming`. Host code that acts on + `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. Nor does a + turn with no trigger, such as a heartbeat, come to speak there: where it speaks moves only with + `channels/incoming` messages. +- **Hosts with modules that close channels:** a module's `channel_close` through its own tool call + (`ctx.callTool`) without a machine source is now recorded with the source `module-tool`, where it + was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, but gives + no note, since the module owns the way back. In connectome-host that's the `mentions` channel + mode: a reply to a mention in a channel in that mode stays a reply, and the invitation there says + the channel stays closed. Policy doesn't lift such a close, and a machine's close doesn't replace + it. Of the agent's close and a module's, the later one is the one on record. A module's close is + never refused as a machine's close of an explicit open; only one naming a machine source is. - **Who needs to act:** a module's close made before this change was recorded as `agent-tool`, which can't be told apart from the agent's own, so after the upgrade such a channel reads as one the agent closed, and the agent's words there carry the note. Setting the channel's mode @@ -77,11 +86,11 @@ `stays-closed`, for a channel a send leaves closed without trying to open it: one the agent closed or declined, one a module closed, or a closed one on a server that's up but whose grant lacks `channels.lifecycle`, where it answered `open-failed` or opened the channel. For an open - channel on such a server that isn't decided open, it answers `already-open` without trying an open, - where it answered `open-failed`. `speechOpens(channel)` says whether the agent's words there - open the channel. `closedByAgent(channel)`, `closedByDecision(channel)` and + channel on such a server that isn't decided open, it answers `already-open` without trying an + open, where it answered `open-failed`. `speechOpens(channel)` says whether the agent's words + there open the channel, and `speechLeavesClosed(channel)` whether they go into it and leave it + closed. `closedByAgent(channel)`, `lifecycleWithheld(serverId)`, `tuneOutEnd(channel)` and `resolveSendTarget(channelId, serverId?)`, which names the one channel a send went to from a - composite or raw id, and opens nothing, are new. -- **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so - the first request after the upgrade that carries those tools misses the cache from its tool - block on. + composite or raw id, and opens nothing, are new too. +- **Prompt cache:** `channel_close`'s description and `agent_settings`' input schema change, so the + first request after the upgrade that carries those tools misses the cache from its tool block on. diff --git a/changelog.d/agent-close-holds.fixed.md b/changelog.d/agent-close-holds.fixed.md index 33b55a4fe..a338c4c4c 100644 --- a/changelog.d/agent-close-holds.fixed.md +++ b/changelog.d/agent-close-holds.fixed.md @@ -1,8 +1,8 @@ - On a server that's up but whose grant lacks `channels.lifecycle`, speech, a draft's resend or a send's open step no longer tries to open a channel that isn't open, since nothing opens there: the words go into the channel as it stands, and a send answers `already-open` or `stays-closed`. - Before, speech and resends there were refused, with "… this host isn't permitted to open or close the … - server's channels", or with "the channel isn't confirmed open" where a retarget had left it + Before, speech and resends there were refused, with "… this host isn't permitted to open or close + the … server's channels", or with "the channel isn't confirmed open" where a retarget had left it unconfirmed, and a send's open failed and was traced as `mcpl:channel-open-failed`. - The latest deliberate close is the one on record: the agent's `channel_close` over its own decline, its `channel_decline` over its own close, and either over a module's close, or a @@ -10,18 +10,18 @@ declined kept the decline when it closed it, and the reverse. A machine close of a channel with no record at all, one registered lazily or on a server whose grant lacks `channels.lifecycle`, now leaves it the default it had, where it used to record its own source, which policy never - lifts (#417). Records written before this change keep their source, so a channel a machine - closed before the upgrade still isn't opened by policy. + lifts (#417). Records written before this change keep their source, so a channel a machine closed + before the upgrade still isn't opened by policy. - `channel_decline` of a channel decided open that isn't open at the transport, whose open failed or is still on its way, now records the decline, where it was refused with "… is open, or set to open, so there's nothing to decline". That's the decline the invitation offers as the way to - reply without opening a channel. Where its transport may be open, as with an open on its way, - the decline closes it through the channel's lifecycle queue, as any decline does. A channel open - at the transport is still refused, now with "… is open, so there's nothing to decline". -- `channel_close`'s description and the "[channels] Now open" and policy notices' last sentence - now say that a channel the agent closes stays closed until it opens it with `channel_open`, and - that neither speaking there, the subscription policy nor its server's own default opens it - again. + reply without opening a channel. Where its transport may be open, as with an open on its way, the + decline closes it through the channel's lifecycle queue, as any decline does. A channel open at + the transport is still refused, now with "… is open, so there's nothing to decline", and so is a + tuned-out channel, as before. +- `channel_close`'s description and the "[channels] Now open" and policy notices' last sentence now + say that a channel the agent closes stays closed until it opens it with `channel_open`, and that + neither speaking there, the subscription policy nor its server's own default opens it again. - The closed-channel invitation is no longer added to a direct message (`chat:dm`), all of which reaches the agent whether the DM is open or not. Where no single-word target reaches a channel, it no longer says to open the channel to reply: it suggests checking whether the server has a diff --git a/src/framework.ts b/src/framework.ts index f518a882e..188498cf3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -507,9 +507,11 @@ function inboundOnlySpeechNote(serverId: string): string { /** * Explicit delivery tools whose successful use into a closed channel OPENS - * it, unless the agent closed or declined it itself or a module closed it - * (send implies engagement — see openIfClosedForSend and speechOpens). Bare - * tool names, matched after stripping the MCPL server prefix. + * it where speech opens it: not where the agent closed or declined it + * itself or a module closed it, nor on a server that's up but whose + * lifecycle this host doesn't direct (send implies engagement — see + * openIfClosedForSend and speechOpens). Bare tool names, matched after + * stripping the MCPL server prefix. */ const SEND_ENGAGEMENT_TOOLS = new Set([ 'send_message', 'reply_message', 'send_dm', 'send_file', 'send_files', @@ -6447,7 +6449,7 @@ export class AgentFramework { }; } - /** What an agent is told when their words go to a channel that stays closed + /** What `who` is told when their words go to a channel that stays closed * because it closed or declined the channel itself (ChannelRegistry * closedByAgent), or undefined where nothing needs saying. Speech also * leaves a channel a module closed as it stands (speechOpens), but there @@ -6456,25 +6458,34 @@ export class AgentFramework { * did, so the agent meets the change here, where speaking used to reopen * what it had closed. The channel is read on the server the words went * to. A channel whose transport is open, as when its server refused the - * agent's close, gets none, since its traffic still flows. What a closed - * channel still delivers beyond messages that address the agent is the - * connector's (slack-mcpl delivers ambient traffic by its own - * subscription), so the note promises only those, and what channel_open - * does where this host directs the server's lifecycle (channels.lifecycle). - * One connector state the host can't see makes the second sentence false: - * a channel muted with discord-mcpl's mute_channel delivers nothing, - * messages that address the agent included. The agent that muted it - * knows. */ - private closedChannelNoteFor(channel: ChannelRef): string | undefined { + * agent's close, gets none, since its traffic still flows. + * + * The rest is judged for its reader, as the opened notices' advice is + * (channelOpenedNotices). That messages addressing it still reach it is + * said where they land in its window: a push, and off a host that routes + * conversations a channel's incoming message, is stored with the delivery + * agent, where the residents' shared slot is read (residentHoldsTraffic), + * and an ephemeral run's window is its own. What a closed channel + * delivers beyond those is the connector's (slack-mcpl delivers ambient + * traffic by its own subscription), so the note promises only those. One + * connector state the host can't see makes that sentence false: a channel + * muted with discord-mcpl's mute_channel delivers nothing, messages that + * address the agent included. The agent that muted it knows. channel_open + * is offered where the reader may call it (canAgentUseTool) and this host + * may open the channel: everywhere but a server that's up without + * channels.lifecycle (lifecycleWithheld), since a reconnecting server's + * grant is empty only until its next exchange, and the note stays in the + * window after that. */ + private closedChannelNoteFor(who: string, channel: ChannelRef): string | undefined { const registry = this.channelRegistry; if (!registry || registry.isChannelOpen(channel)) return undefined; const how = registry.closedByAgent(channel); if (!how) return undefined; - const reach = 'Messages there that address you, such as an @-mention, still reach you'; - const open = registry.directsLifecycle(channel.serverId) - ? `; channel_open with channelId "${channel.channelId}" and serverId "${channel.serverId}" opens it.` - : '.'; - return `${this.channelShown(channel)} stays closed: you ${how} it, and your words there don't open it. ${reach}${open}`; + const reach = this.residentHoldsTraffic(who) ? ' Messages there that address you, such as an @-mention, still reach you.' : ''; + const open = this.canAgentUseTool(who, 'channel_open') && !registry.lifecycleWithheld(channel.serverId) + ? ` channel_open with channelId "${channel.channelId}" and serverId "${channel.serverId}" opens it.` + : ''; + return `${this.channelShown(channel)} stays closed: you ${how} it, and your words there don't open it.${reach}${open}`; } /** What an agent is told when a send tool's words go to a channel it tuned @@ -6492,17 +6503,26 @@ export class AgentFramework { if (!registry || this.conversationAgentHomes.has(agentName)) return undefined; if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out') return undefined; if (!this.closedChannelNoteOn(agentName)) return undefined; + // How it ends is tune_out's own refusals' sentence, which names the + // channel so tune_out picks it alone (ChannelRegistry.tuneOutEnd), for + // a reader that may call tune_out. + const end = this.canAgentUseTool(agentName, 'tune_out') ? registry.tuneOutEnd(channel) : undefined; return `${this.channelShown(channel)} stays tuned out: your words there don't end the tune-out, so its ` + - 'traffic still goes to your subconscious until the tune-out ends. ' + - `tune_out with channelId "${channel.channelId}" and mode "cancel" resumes normal attention there.`; + `traffic still goes to your subconscious until the tune-out ends.${end ? ` ${end}` : ''}`; } catch (err) { console.error(`[closed-channel-note] for ${agentName} failed: ${err instanceof Error ? err.message : String(err)}`); return undefined; } } - /** How to stop the closed-channel note, said once wherever it appears. */ - private static readonly CLOSED_CHANNEL_NOTE_OFF = 'To stop this note, set closed_channel_note to false with agent_settings.'; + /** How to stop the closed-channel note, said once wherever it appears, to + * a reader that may call agent_settings (canAgentUseTool), with the space + * before it; nothing to one that may not. */ + private closedChannelNoteOff(agentName: string): string { + return this.canAgentUseTool(agentName, 'agent_settings') + ? ' To stop this note, set closed_channel_note to false with agent_settings.' + : ''; + } /** The closed-channel notes for an agent's words that landed in these * channels, or none when they've turned the note off. A conversation agent @@ -6514,7 +6534,7 @@ export class AgentFramework { private closedChannelNotes(agentName: string, channels: Iterable): string[] { try { if (this.conversationAgentHomes.has(agentName)) return []; - const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(channel) ?? []); + const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []); // The setting is read only when there's a note to give. return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : []; } catch (err) { @@ -6562,7 +6582,7 @@ export class AgentFramework { if (window) { this.addMessage('user', [{ type: 'text', - text: `${tag} Your script's ${toolName}: ${body} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}`, + text: `${tag} Your script's ${toolName}: ${body}${this.closedChannelNoteOff(agentName)}`, }], { system: true, kind: 'channel-notice' } as MessageMetadata, window); } } catch (err) { @@ -6570,7 +6590,7 @@ export class AgentFramework { } return answer; } - const text = `${tag} ${body} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}`; + const text = `${tag} ${body}${this.closedChannelNoteOff(agentName)}`; const data = Array.isArray(answer.data) ? [...answer.data, { type: 'text', text }] : typeof answer.data === 'string' && answer.data.length > 0 ? `${answer.data}\n\n${text}` : text; @@ -13981,23 +14001,28 @@ export class AgentFramework { // What replying does here is the registry's to say (speechOpens): a reply // opens a channel nobody decided to keep closed, and leaves one the agent // closed or declined itself, or a module closed, as it stands, as it does - // a channel on a server whose lifecycle this host doesn't direct. + // a channel on a server that's up but whose lifecycle this host doesn't + // direct. const replyOpens = this.channelRegistry.speechOpens(channel); + // The decline is the way to reply without opening a channel, except in a + // tuned-out one, which channel_decline refuses: the tune-out ends first. const withoutOpening = - "To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there."; + this.channelRegistry.getDesiredState(opts.serverId, opts.channelId) === 'tuned-out' + ? '' + : " To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there."; const replyOption = inboundOnlyServer ? `1. Replying there isn't available: ${inboundOnlyServer} declared this channel inbound-only, so nothing ` + `you write is posted into it. To speak there, use ${inboundOnlyServer}'s own tools.\n` : target ? replyOpens ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, which opens ` + - `the channel. ${withoutOpening}\n` + `the channel.${withoutOpening}\n` : `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, and the ` + `channel stays closed.\n` : `1. Reply — no single-word target reaches this channel unambiguously, ` + `so a ">>" prefix can't name it; check whether ${opts.serverId} exposes a send tool that can address it.` + (replyOpens - ? ` Replying there opens the channel. ${withoutOpening}\n` + ? ` Replying there opens the channel.${withoutOpening}\n` : ` Replying there leaves the channel closed.\n`); const missedCount = opts.missedMessages ?? 0; const missedNote = @@ -16507,7 +16532,7 @@ export class AgentFramework { // so, unless the agent has turned the note off. const closedNotes = this.closedChannelNotes(agent.name, landed); const closedNote = closedNotes.length > 0 - ? ` · ${closedNotes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` + ? ` · ${closedNotes.join(' ')}${this.closedChannelNoteOff(agent.name)}` : ''; // In disabled mode, routing posts no plain speech to any channel, and the // words stay in the transcript. The receipt says only that. Who watched @@ -16562,7 +16587,7 @@ export class AgentFramework { try { this.addMessage( 'user', - [{ type: 'text', text: `[closed channel] ${notes.join(' ')} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` }], + [{ type: 'text', text: `[closed channel] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }], { system: true, kind: 'delivery-receipt' } as MessageMetadata, target, ); @@ -30139,18 +30164,20 @@ export class AgentFramework { if (announce && this.channelRegistry) { const registry = this.channelRegistry; // The default words, for the channel they go to. Speech leaves a - // channel the agent closed or declined itself, or a module closed, as it - // stands (speechOpens), so there the agent will still see only messages - // addressed to it, and the words say so. Elsewhere they read as they - // always did. "Messages addressed to me" is true in an open channel - // too, so those are the words used when the channel's state can't be - // read. It's read as the announcement goes out, since that doesn't + // closed channel the agent closed or declined itself, or a module + // closed, or one on a server that's up without channels.lifecycle, + // closed (speechLeavesClosed), so there what reaches the agent is what + // its server delivers from a closed channel, messages addressed to it + // at least, and the words promise only those. Elsewhere the channel is + // open, or the announcement opens it, and they read as they always + // did. "Messages addressed to me" is true in an open channel too, so + // those are the words used when the channel's state can't be read. It's read as the announcement goes out, since that doesn't // depend on the post landing, which stays fire-and-forget. An agent's // own words are posted as given. const defaultWords = (channel: ChannelRef | null): string => { let staysClosed = false; try { - staysClosed = channel !== null && registry.closedByDecision(channel); + staysClosed = channel !== null && registry.speechLeavesClosed(channel); } catch (err) { staysClosed = true; console.error(`[sleep] ${agentName}: whether the channel stays closed couldn't be read: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index f61a8b314..83d234347 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -135,6 +135,19 @@ class ChannelLifecycleError extends Error { } } +/** Whether a lifecycle failure, or one it wraps (an overtaken open's + * `cause`), leaves in doubt which channel the server reaches by this id: + * convergence that never established the channel's target, or an answer + * for another channel. Words don't go into such a channel on the strength + * of a close the agent decided meanwhile (refusalGivesWay), since the + * server may not resolve their publish to it either. */ +function targetInDoubt(err: unknown): boolean { + for (let e: unknown = err; e instanceof Error; e = e.cause) { + if (e instanceof ChannelLifecycleError && (e.kind === 'converge' || e.kind === 'substituted')) return true; + } + return false; +} + /** Why a channel is selected for opening after a failed open by speech or a * reply: its put-back couldn't be recorded, or the server's latest * announcement or the subscription policy lifted the default it restored. */ @@ -1280,7 +1293,7 @@ export class ChannelRegistry { /** Whether a decided close holds words back from a channel: only where * the host directs the server's lifecycle. Without that grant the host * can't close the channel, and words go into a transport that is open, - * as the open step's engagesDecidedClosed reads it too. */ + * as the open step reads it too (speechOpens). */ private closeStands(entry: ChannelEntry): boolean { return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' && this.directsLifecycle(entry.serverId); @@ -2969,8 +2982,8 @@ export class ChannelRegistry { // close, records itself over any closed record from another source, // another deliberate close included, so a decline after a close is the // decision on record. Speech opens no channel whose close was decided - // (speechOpens). A machine's close never replaces a record, so what - // nobody decided stays liftable. + // (speechOpens). A machine's close never replaces a closed record, so + // what nobody decided stays liftable. const agentOverOther = AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current?.source ?? ''); const latestClose = desired === 'closed' && isCloseDecision(source) && current?.source !== source; if (current?.state === desired && !agentOverOther && !latestClose) { @@ -3929,6 +3942,13 @@ export class ChannelRegistry { * For channel_close, which refuses a tuned-out channel too, it adds that * the close comes after that. */ + /** How this channel's tune-out ends, as tune_out's own refusals say it + * (tuneOutEndText), or undefined for a channel no longer registered. */ + tuneOutEnd(ref: ChannelRef): string | undefined { + const entry = this.entryAt(ref); + return entry ? this.tuneOutEndText(entry) : undefined; + } + private tuneOutEndText(entry: ChannelEntry, tool?: string): string { const close = tool === 'channel_close'; return this.nameFor(entry) !== undefined @@ -4707,31 +4727,53 @@ export class ChannelRegistry { * the agent made itself (channel_close, channel_decline) or a module made * (MODULE_CLOSE_SOURCE), which stands against the agent's own speech * whatever the channel's transport is; or its server is up and this host - * doesn't direct its lifecycle (channels.lifecycle), so nothing opens + * doesn't direct its lifecycle (lifecycleWithheld), so nothing opens * there. A wait or an open that failed gives way to such a registration, - * as when the agent closed the channel while the words waited: there's - * nothing for the words to wait for. */ + * as when the agent closed the channel while the words waited, since + * there's nothing for the words to wait for (refusalGivesWay). */ private speechLeavesAsItStands(entry: ChannelEntry | undefined): entry is ChannelEntry { if (!entry) return false; - return this.closeDecided(refOf(entry)) || - (!this.directsLifecycle(entry.serverId) && this.serverReach(entry.serverId) === 'up'); - } - - /** Whether this channel is closed because the agent or a module decided - * to keep it closed (the agent's own close or decline, or a module's - * close), which speech never opens (speechOpens). False for a channel - * open at the transport, whose traffic still flows. */ - closedByDecision(ref: ChannelRef): boolean { + return this.closeDecided(refOf(entry)) || this.lifecycleWithheld(entry.serverId); + } + + /** Whether words a wait or an open refused go into the channel after all: + * its registration `now` is one speech leaves as it stands + * (speechLeavesAsItStands), such as one the agent closed or declined + * while the words waited, and the failure doesn't leave in doubt which + * channel the server reaches by its id (targetInDoubt). A registration + * that went still fails them. */ + private refusalGivesWay(err: unknown, now: ChannelEntry | undefined): now is ChannelEntry { + return this.speechLeavesAsItStands(now) && !targetInDoubt(err); + } + + /** Whether this host doesn't direct the server's channel lifecycle: the + * server is up, and its grant lacks channels.lifecycle, so nothing opens + * or closes its channels. A server that isn't up has an empty grant from + * a drop until its next exchange, which says nothing yet of what it + * grants, so it isn't counted. */ + lifecycleWithheld(serverId: string): boolean { + return !this.directsLifecycle(serverId) && this.serverReach(serverId) === 'up'; + } + + /** Whether the agent's words go into this channel and leave it closed: it + * isn't open at the transport, and speech leaves it as it stands + * (speechLeavesAsItStands: the agent's or a module's close, or a server + * that's up without channels.lifecycle). False for a channel open at the + * transport, whose traffic still flows, and for one speech opens. */ + speechLeavesClosed(ref: ChannelRef): boolean { const entry = this.entryAt(ref); - return !!entry && !entry.open && this.closeDecided(ref); + return !!entry && !entry.open && this.speechLeavesAsItStands(entry); } /** - * How the agent kept this channel closed itself, if it did: 'closed' for - * its channel_close, 'declined' for its channel_decline. Its speech there - * leaves such a channel as it stands (speechOpens). + * How the agent kept this registered channel closed itself, if it did: + * 'closed' for its channel_close, 'declined' for its channel_decline. Its + * speech there leaves such a channel as it stands (speechOpens). A + * channel no longer registered has nothing to keep closed, whatever its + * record says, so it answers undefined. */ closedByAgent(ref: ChannelRef): 'closed' | 'declined' | undefined { + if (!this.entryAt(ref)) return undefined; const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId)); if (current?.state !== 'closed') return undefined; if (current.source === 'agent-tool') return 'closed'; @@ -4835,9 +4877,11 @@ export class ChannelRegistry { } catch (err) { // A channel the send now leaves as it stands, such as one the agent // closed or declined while queued work or the open was out, answers as - // one it found so: nothing failed that the send would have done. + // one it found so: nothing failed that the send would have done. A + // failure that leaves its target in doubt is still reported + // (refusalGivesWay). const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id }); - if (this.speechLeavesAsItStands(now)) return this.sendLeavesChannel(now); + if (this.refusalGivesWay(err, now)) return this.sendLeavesChannel(now); // Where a decision made during the open's round-trip replaced it, such // as the agent's channel_open, the channel is as that decision left it, // and the agent isn't told the open failed. @@ -4858,7 +4902,8 @@ export class ChannelRegistry { } } - /** What a send answers for a channel it leaves as it is (speechOpens): + /** What a send answers for a channel it leaves as it is + * (speechLeavesAsItStands): * `already-open` where its transport is open with no close standing, * `stays-closed` otherwise. */ private sendLeavesChannel(entry: ChannelEntry): { status: 'already-open' | 'stays-closed'; channelId: string; serverId: string; label?: string } { @@ -5624,10 +5669,10 @@ export class ChannelRegistry { * it's opened first (`openSource` names why), once its server and that * server's channels.publish grant are known to take the send, so a send * refused before the open opens nothing; and a failed open sends nothing. - * A channel speech leaves as it stands, one the agent closed or declined - * itself or a module closed, or a closed one on a server whose lifecycle - * this host doesn't direct, takes the words as it is, once its queued - * lifecycle work has settled. + * A channel speech leaves as it stands (speechLeavesAsItStands), one the + * agent closed or declined itself or a module closed, or a closed one on + * a server that's up but whose lifecycle this host doesn't direct, takes + * the words as it is, once its queued lifecycle work has settled. * * A channel its server declared inbound-only (isInboundOnly) takes nothing * the host would post, whichever path the words took, a drafts resend @@ -5706,12 +5751,13 @@ export class ChannelRegistry { // Words go into a channel that's open with no close standing, or into // one speech leaves as it stands (speechLeavesAsItStands): a channel the // agent closed or declined itself, or a module closed, and a closed - // channel on a server whose lifecycle this host doesn't direct, where - // nothing opens. Every other channel speech opens first (speechOpens), - // and if it doesn't open, nothing goes out. A wait or an open that fails - // is read against the channel as it is then, so a close the agent - // decides while the words wait lets them go into the channel as that - // close leaves it. + // channel on a server that's up but whose lifecycle this host doesn't + // direct, where nothing opens. Every other channel speech opens first + // (speechOpens), and if it doesn't open, nothing goes out. A wait or an + // open that fails is read against the channel as it is then, so a close + // the agent decides while the words wait lets them go into the channel + // as that close leaves it, unless the failure leaves in doubt which + // channel the server reaches by its id (refusalGivesWay). // Lifecycle work already queued for the channel settles first: words go // out only into the channel it leaves, and fail visibly if it leaves @@ -5721,7 +5767,7 @@ export class ChannelRegistry { entry = await this.waitForOpenChannel(entry); } catch (err) { const now = this.entryAt(refOf(entry)); - if (!this.speechLeavesAsItStands(now)) return prevented(err); + if (!this.refusalGivesWay(err, now)) return prevented(err); entry = now; } } @@ -5765,7 +5811,7 @@ export class ChannelRegistry { } } catch (err) { const now = this.entryAt(refOf(entry)); - if (!this.speechLeavesAsItStands(now)) return { status: 'failed', destination, reason: openFailedReason(err), at: at() }; + if (!this.refusalGivesWay(err, now)) return { status: 'failed', destination, reason: openFailedReason(err), at: at() }; entry = now; } } @@ -5784,15 +5830,15 @@ export class ChannelRegistry { // again on the registration that stands. waitForOpenChannel returns // only a registration it found open and whose close doesn't stand, or // throws, so the loop ends unless the channel keeps changing. A channel - // speech leaves as it stands (speechLeavesAsItStands) needs none of that, and - // its words go into it as it is. + // speech leaves as it stands (speechLeavesAsItStands) needs none of + // that, and its words go into it as it is. const key = destinationKey({ serverId: entry.serverId, channelId: entry.descriptor.id }); let current = this.channels.get(key); while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) { try { entry = await this.waitForOpenChannel(current); } catch (err) { - if (!this.speechLeavesAsItStands(this.channels.get(key))) return prevented(err); + if (!this.refusalGivesWay(err, this.channels.get(key))) return prevented(err); } current = this.channels.get(key); } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index a0c6bab1a..085fcb7ad 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1538,7 +1538,7 @@ export class McplServerConnection extends EventEmitter { } /** Send `channels/open` request and await result. It keeps a deadline as - * channels/publish does: a delivery opens a closed channel before it + * channels/publish does: a delivery can open a closed channel before it * publishes, so an open that is never answered would hold the delivery, * and the turn that waits for it, for good. */ sendChannelsOpen(params: ChannelsOpenParams): Promise { diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index c0e604f95..668c35291 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -445,6 +445,31 @@ test('a channel retargeted while its opening is announced takes the words only o await announced; }); +test("a channel retargeted while its opening is announced takes the words as it stands once the agent closes it while they wait", async () => { + // The check after the open waits for the new target's open. The agent's + // close decided meanwhile stands against nothing the words need, so they + // go into the channel as that close leaves it, opening nothing more. + let announced: Promise | undefined; + let f!: ReturnType; + f = fixture(() => { + f.held.add('x'); + announced = f.registry.handleChanged('test', { updated: [descriptor('x', false, 'retargeted')] }); + }); + await f.registry.handleChanged('test', { added: [descriptor('x')] }); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + for (let i = 0; i < 5; i++) await tick(); + assert.equal(f.pending.length, 1, "the new target's open is in flight"); + assert.equal(f.publishedWhileOpen.length, 0); + const close = f.tool('close'); + await f.drain(speech, close, announced!); + assert.equal((await speech)?.delivered, true); + assert.equal(f.publishedWhileOpen.length, 1, 'the words were published once'); + assert.deepEqual(f.routeFailures, []); + assert.equal((await close).success, true); + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); + assert.equal(f.isOpen('x'), false); +}); + test('a channel registered again while the words wait for a new registration takes them only once the newest is open', async () => { // The server registers x again while the words wait for the first new // registration's open. The words wait through the newer one's open too, @@ -676,6 +701,64 @@ test("an open answered for another channel applies nothing of its answer to the assert.equal(f.registry.inboundOnlyServer({ serverId: 'test', channelId: 'x' }), undefined, "the other channel's declaration isn't applied"); }); +for (const via of ['speech', 'a send'] as const) { + test(`${via} whose open was answered for another channel isn't let through by the agent's close made while that open was out`, async () => { + // An answer for another channel leaves in doubt which channel the server + // reaches by this id, and a publish by that id may not reach it either. + // A close the agent decides while the open is out lets words go into the + // channel as it stands only where the failure says nothing of the target. + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x'), descriptor('y')] }); + f.answeredFor.set('x', 'y'); + f.held.add('x'); + const outcome = via === 'speech' + ? f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }) + : f.registry.openIfClosedForSend('x', 'test'); + await tick(); + assert.equal(f.pending.length, 1, 'the open is in flight'); + const close = f.tool('close'); + await f.drain(outcome, close); + assert.equal((await close).success, true); + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); + if (via === 'speech') { + assert.equal(await outcome, null); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.match(f.routeFailures[0]?.reason ?? '', /answered for a different channel/); + } else { + assert.equal(((await outcome) as { status: string }).status, 'open-failed', "not answered as a channel the send left closed"); + } + }); +} + +test("words for a channel the agent closed aren't let through where convergence never established the channel's target", async () => { + // The words wait on the agent's close, retargeted before each receipt until + // its tries run out. The close stands, but the server's channel for this id + // was never established, so the words aren't published into it. + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x')] }); + assert.equal((await f.tool('open')).success, true); + f.held.add('x'); + const close = f.tool('close'); + await tick(); + assert.equal(f.pending.length, 1, 'the close is in flight'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + try { + for (let i = 0; i < 12 && f.pending.length > 0; i++) { + await f.registry.handleChanged('test', { updated: [descriptor('x', false, 'churn-' + i)] }); + f.pending.shift()!.finish(); + await tick(); + } + } finally { + f.held.delete('x'); + while (f.pending.length > 0) f.pending.shift()!.finish(); + } + assert.equal(await speech, null); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.match(f.routeFailures[0]?.reason ?? '', /kept changing, and 5 tries didn't settle it/); + assert.equal((await close).success, false); + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); +}); + test("an open's answer for a target the channel has since left applies nothing; the new target's answer decides", async () => { const f = fixture(); f.declares.set('original', 'inbound'); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index f6941fa5e..6aec75ab3 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -402,8 +402,11 @@ test('ensureChannelRegistered is idempotent and does not reopen a closed channel }); // --------------------------------------------------------------------------- -// Channel-lifecycle invariants (2026-07-22): sending into a closed channel is -// not a thing — delivery opens it; subscribed ⇒ open at any discovery time. +// Channel-lifecycle invariants (2026-07-22): delivery opens a closed channel +// speech opens (speechOpens), so nothing is sent into one it would open +// unopened; a channel the agent or a module closed, or one on a server that's +// up without channels.lifecycle, takes the words as it stands. Subscribed ⇒ +// open at any discovery time. // --------------------------------------------------------------------------- test('routeSpeech into a closed locus opens the channel first, then delivers', async () => { @@ -671,7 +674,7 @@ test("a module's close, made with no machine source, is the module's: speech lea assert.equal(registry.getDesiredState('discord', id), 'closed'); assert.equal(registry.speechOpens(discord(id)), false, `${id}: speech leaves it closed`); assert.equal(registry.closedByAgent(discord(id)), undefined, `${id}: not the agent's close`); - assert.equal(registry.closedByDecision(discord(id)), true, `${id}: a decided close all the same`); + assert.equal(registry.speechLeavesClosed(discord(id)), true, `${id}: a decided close all the same, which speech leaves closed`); } const opensBefore = openCalls.length; assert.deepEqual(await registry.routeSpeech('sol', 'a reply', discord('mode-a')), { delivered: true, serverId: 'discord', channelId: 'mode-a' }); @@ -827,9 +830,22 @@ test("words waiting on an open speech started go into the channel as it stands o assert.deepEqual(failures, [], 'and no words were refused'); }); -test("words go into a channel the agent closed whose close is still on its way, or whose close failed and left its transport open, without opening it", async () => { +test("a channel no longer registered isn't one the agent keeps closed, whatever its record says", async () => { + const { registry } = makeRegistry({ delivered: true }); + await registry.handleChanged('discord', { added: channelsOf('gone') } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'gone' }, agent)).success, true); + assert.equal(registry.closedByAgent(discord('gone')), 'closed'); + assert.equal(registry.speechLeavesClosed(discord('gone')), true); + await registry.handleChanged('discord', { removed: ['gone'] } as never); + assert.equal(registry.getDesiredState('discord', 'gone'), 'closed', 'the decision is kept'); + assert.equal(registry.closedByAgent(discord('gone')), undefined); + assert.equal(registry.speechLeavesClosed(discord('gone')), false); +}); + +test("words go into a channel the agent closed whose close is still on its way, without opening it", async () => { const { registry, openCalls, closeCalls, published, holdOpens } = loggedRegistry([]); - await registry.handleRegister('discord', { channels: channelsOf('closing', 'refused') } as never); + await registry.handleRegister('discord', { channels: channelsOf('closing') } as never); const agent = { kind: 'agent' as const, agentName: 'sol' }; assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'closing' }, agent)).success, true); // An open still being answered holds the channel's lifecycle queue, so the diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index c1f98f2e7..4f030e6c9 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -2145,7 +2145,7 @@ describe('explicit prose routing', () => { } const CAFE_NOTE = "[closed channel] #cafe (discord:g:cafe) stays closed: you closed it, and your words there don't open it. " + - 'Messages there that address you, such as an @-mention, still reach you; ' + + 'Messages there that address you, such as an @-mention, still reach you. ' + 'channel_open with channelId "discord:g:cafe" and serverId "discord" opens it. ' + 'To stop this note, set closed_channel_note to false with agent_settings.'; @@ -2171,6 +2171,21 @@ describe('explicit prose routing', () => { await framework.stop(); }); + it('speech into a closed channel twice in one turn gets its note once', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe hello there\n>>#alpha and everyone\n>>#cafe and again' }, + ] as ContentBlock[])); + const framework = await createFramework(); + const routed = closedNoteRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe', 'chan-alpha', 'discord:g:cafe']); + assert.deepEqual(windowTexts(framework).filter((t) => t.startsWith('[closed channel]')), [CAFE_NOTE]); + await framework.stop(); + }); + it('a turn a tool ends gets the note once its speech has been delivered', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#cafe heading out now' }, diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 77876ff78..ad1e8d309 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -43,7 +43,7 @@ const agentStub = (more: Record = {}) => ({ /** The note a send's answer carries when it lands in #upkeep, which the agent closed. */ const UPKEEP_NOTE = `[closed channel] #upkeep (${channelId}) stays closed: you closed it, and your words there don't open it. ` + - 'Messages there that address you, such as an @-mention, still reach you; ' + + 'Messages there that address you, such as an @-mention, still reach you. ' + `channel_open with channelId "${channelId}" and serverId "discord" opens it. ` + 'To stop this note, set closed_channel_note to false with agent_settings.'; @@ -65,6 +65,8 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { let refuseSends = false; // While set, the server refuses channels/close. let refuseCloses = false; + // While set, the send's answer is this content instead of discord-mcpl's receipt. + let answer: unknown[] | null = null; const published: Array<{ channelId: string; text: string }> = []; const server = { id: 'discord', @@ -79,6 +81,7 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { if (refuseSends) { return { content: [{ type: 'text', text: `Missing Access in channel ${sentTo}` }], isError: true }; } + if (answer) return { content: answer }; // discord-mcpl's send tools answer with the posted message and the raw channel id. return { content: [{ type: 'text', text: JSON.stringify({ messageId: 'm9', channelId: sentTo }) }] }; }, @@ -158,9 +161,9 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { * with its result, and the turn's engaged channels once the open the send * started has settled, and what follows it. A script's call (`script`) is * one the framework is waiting on for a script. */ - async function reply(tool = 'reply_message', callId = 'call-reply', script = false): Promise<{ success: boolean; engaged: string[]; data: string; error: string }> { + async function reply(tool = 'reply_message', callId = 'call-reply', script = false): Promise<{ success: boolean; engaged: string[]; data: string; raw: unknown; error: string }> { if (script) framework.scriptToolWaiters = new Map([[callId, () => {}]]); - const out = await new Promise<{ success: boolean; data: string; error: string }>((resolve, reject) => { + const out = await new Promise<{ success: boolean; data: string; raw: unknown; error: string }>((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('timed out waiting for the tool result')), 1000); framework.pushEvent = (event: { type: string; callId?: string; result: { success: boolean; data?: unknown; error?: string } }) => { if (event.type === 'tool-result' && event.callId === callId) { @@ -168,6 +171,7 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { resolve({ success: event.result.success, data: String(event.result.data ?? ''), + raw: event.result.data, error: String(event.result.error ?? ''), }); } @@ -200,7 +204,13 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { setSentTo: (raw: string) => { sentTo = raw; }, setRefuseSends: (v: boolean) => { refuseSends = v; }, setRefuseCloses: (v: boolean) => { refuseCloses = v; }, + setAnswer: (content: unknown[] | null) => { answer = content; }, setGrant: (grant: CapabilityGrant) => { server.grant = grant; }, + /** The server's connection, as a drop leaves it: not connected, the host + * reconnecting to it, its grant empty until its next exchange. */ + dropConnection: () => { + Object.assign(server, { isConnected: false, willReconnect: true, grant: new CapabilityGrant(new Set(), []) }); + }, state: () => frameworkState as { closedChannelNotes?: Record } | null, }; } @@ -266,6 +276,15 @@ test("a note that can't be worked out leaves the delivered words as they were, a assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: undefined']); registry.closedByAgent = closedByAgent; + // The channel the send went to can't be looked up. + const resolveSendTarget = registry.resolveSendTarget; + registry.resolveSendTarget = () => { throw new Error('target lookup failed'); }; + sent = await reply(); + assert.equal(sent.success, true); + assert.equal(sent.data, answer); + assert.deepEqual(logged.splice(0), ['[closed-channel-note] for sol failed: target lookup failed']); + registry.resolveSendTarget = resolveSendTarget; + // Routed speech's delivery line stands without the note. const lines: string[] = []; const agent = { @@ -297,6 +316,96 @@ test("on a server whose grant lacks channels.lifecycle, the note doesn't offer c 'To stop this note, set closed_channel_note to false with agent_settings.'), data); }); +test("the note offers only what its reader can use: channel_open and agent_settings where its allowedTools hold them, and what still reaches it where those messages land in its window", async () => { + const { registry, framework, reply } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + const head = `[closed channel] #upkeep (${channelId}) stays closed: you closed it, and your words there don't open it.`; + const reach = ' Messages there that address you, such as an @-mention, still reach you.'; + const open = ` channel_open with channelId "${channelId}" and serverId "discord" opens it.`; + const off = ' To stop this note, set closed_channel_note to false with agent_settings.'; + const noteOf = async () => { + const { success, data } = await reply(); + assert.equal(success, true); + return data.slice(data.indexOf('[closed channel]')); + }; + assert.equal(await noteOf(), head + reach + open + off); + // An agent whose allowedTools leave a tool out isn't offered it. + framework.agents.set('sol', agentStub({ canUseTool: (tool: string) => tool !== 'channel_open' })); + assert.equal(await noteOf(), head + reach + off); + framework.agents.set('sol', agentStub({ canUseTool: (tool: string) => tool !== 'agent_settings' })); + assert.equal(await noteOf(), head + reach + open); + // An agent whose window isn't where those messages land, as an ephemeral + // run's is its own, isn't told they reach it: here the delivery agent is + // another, and sol doesn't read the residents' shared slot. + framework.agents = new Map([['primary', agentStub()], ['sol', agentStub()]]); + framework.sharedSlotAgents = new Set(); + assert.equal(await noteOf(), head + open + off); +}); + +test("a note written while its server reconnects still offers channel_open, which opens the channel once the server is back", async () => { + const { registry, framework, dropConnection } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + // The turn's speech went out before the drop; its delivery line is written after it. + dropConnection(); + assert.equal(registry.directsLifecycle('discord'), false, 'the grant is empty until the next exchange'); + const lines: string[] = []; + const agent = { + name: 'sol', + proseRouting: 'locus', + getContextManager: () => ({ + addMessage: (_role: string, content: Array<{ text: string }>) => { lines.push(content[0]!.text); return 'm'; }, + }), + }; + framework.appendProseDeliveryReceipt(agent, receiptRouting(channelId)); + assert.equal(lines.length, 1); + assert.ok(lines[0]!.includes(` channel_open with channelId "${channelId}" and serverId "discord" opens it.`), lines[0]); +}); + +test("a send's note goes after the server's own answer, whatever its shape: one more block after an image's, or the note alone where the answer has no words", async () => { + const { registry, reply, setAnswer } = await harness(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + setAnswer([{ type: 'text', text: 'posted' }, { type: 'image', data: png, mimeType: 'image/png' }]); + const withImage = await reply(); + assert.equal(withImage.success, true); + assert.deepEqual(withImage.raw, [ + { type: 'text', text: 'posted' }, + { type: 'image', data: png, mimeType: 'image/png' }, + { type: 'text', text: UPKEEP_NOTE }, + ]); + setAnswer([]); + const empty = await reply(); + assert.equal(empty.success, true); + assert.equal(empty.raw, UPKEEP_NOTE); +}); + +test("a closed-channel invitation in a tuned-out channel doesn't offer the decline as a way to reply without opening it, since channel_decline refuses a tuned-out channel", async () => { + const { registry, framework } = await harness(); + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:777888', type: 'discord', label: '#lobby', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '777888' } }], + } as never); + const invite = (id: string, label: string): string => framework.buildClosedChannelInvitation({ + serverId: 'discord', channelId: id, channelLabel: label, messageId: 'm7', authorName: 'Ann', tags: ['chat:addressed'], + })?.block.text ?? ''; + // Tuned out while its transport is closed, as while tune_out's open is out, or after it failed. + registry.enterTuneOut('discord', channelId, { + epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, + }, 'agent-tool'); + assert.equal(registry.isChannelOpen(ref(channelId)), false); + const tunedOut = invite(channelId, '#upkeep'); + assert.match(tunedOut, /it will be delivered there, which opens the channel\.\n/); + assert.doesNotMatch(tunedOut, /decline it first/); + assert.equal((await registry.handleChannelToolCall('channel_decline', { channelId, messageId: 'm7' }, origin)).success, false, + 'channel_decline refuses it'); + // A closed channel nobody decided about is offered the decline. + assert.match(invite('discord:g1:777888', '#lobby'), + /which opens the channel\. To reply without opening it, decline it first \(option 3\): a channel you've declined stays closed when you speak there\.\n/); +}); + test("a channel the agent closed whose server refused the close is still open, so a send there carries no note", async () => { const { registry, opens, reply, setRefuseCloses } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); @@ -491,8 +600,8 @@ test('a send into a tuned-out channel leaves the tune-out in place, and its answ const opensBefore = opens.length; const tunedOutNote = `[tuned out] #upkeep (${channelId}) stays tuned out: your words there don't end the tune-out, ` + 'so its traffic still goes to your subconscious until the tune-out ends. ' + - `tune_out with channelId "${channelId}" and mode "cancel" resumes normal attention there. ` + - 'To stop this note, set closed_channel_note to false with agent_settings.'; + `Call tune_out with channelId "${channelId}" and mode "cancel" for it to resume normal attention; cancelling ` + + 'delivers the diverted backlog. To stop this note, set closed_channel_note to false with agent_settings.'; for (const tool of ['reply_message', 'send_dm']) { const { success, data } = await reply(tool); @@ -507,11 +616,26 @@ test('a send into a tuned-out channel leaves the tune-out in place, and its answ framework.closedChannelNoteSettingsExtension().update('sol', { closed_channel_note: false }); assert.ok(!(await reply()).data.includes('[tuned out]')); framework.closedChannelNoteSettingsExtension().reset('sol'); + // An agent whose allowedTools leave tune_out out isn't told how to call it. + framework.agents.set('sol', agentStub({ canUseTool: (tool: string) => tool !== 'tune_out' })); + assert.ok((await reply()).data.endsWith('until the tune-out ends. To stop this note, set closed_channel_note to false with agent_settings.')); + framework.agents.set('sol', agentStub()); // A conversation agent gets none. framework.conversationAgentHomes.set('sol', channelId); assert.ok(!(await reply()).data.includes('[tuned out]')); }); +test("a tuned-out channel whose id another server's channel shares is named for tune_out by its server-qualified name, which picks it alone", async () => { + const { registry, reply } = await harness({ sharedIdFirst: true }); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId, serverId: 'discord' }, origin)).success, true); + registry.enterTuneOut('discord', channelId, { + epochId: 'e1', cadenceSeconds: 600, backlogCap: 50, maxWakes: 3, startedAtSequence: 0, + }, 'agent-tool'); + const { success, data } = await reply(); + assert.equal(success, true); + assert.ok(data.includes(`Call tune_out with channelId "discord:${channelId}" and mode "cancel"`), data); +}); + test("a code_execution script's send into a channel the agent closed or tuned out keeps the server's receipt, and the agent is told in its window", async () => { const { registry, framework, windowMessages, windowFor, reply } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); @@ -643,7 +767,7 @@ test("closed_channel_note is an agent_settings key, kept in the framework's stat }); test("a sleep announced into a channel the agent or a module closed says the agent will still see messages addressed to it; elsewhere the post opens the channel as speech does, and says it will still see messages", async () => { - const { registry, framework, published, opens, setRefuseCloses } = await harness(); + const { registry, framework, published, opens, setRefuseCloses, setGrant } = await harness(); await registry.handleChanged('discord', { added: [{ id: 'discord:g1:777888', type: 'discord', label: '#lobby', direction: 'bidirectional', address: { guildId: 'g1', channelId: '777888' } }], @@ -697,10 +821,10 @@ test("a sleep announced into a channel the agent or a module closed says the age framework.closedChannelNoteSettingsExtension().reset('sol'); // When who closed the channel can't be read, the words are ones true either way. - const closedByDecision = registry.closedByDecision.bind(registry); - registry.closedByDecision = () => { throw new Error('the registry is unreadable'); }; + const speechLeavesClosed = registry.speechLeavesClosed.bind(registry); + registry.speechLeavesClosed = () => { throw new Error('the registry is unreadable'); }; slept = await sleep({ seconds: 600 }); - registry.closedByDecision = closedByDecision; + registry.speechLeavesClosed = speechLeavesClosed; assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); // A channel a module closed stays closed too, as connectome-host's mentions mode keeps it. @@ -787,6 +911,22 @@ test("a sleep announced into a channel the agent or a module closed says the age routing.pin = ref(channelId); slept = await sleep({ seconds: 600 }); assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + routing.pin = undefined; + framework.agents.set('sol', agentStub()); + + // On a server that's up without channels.lifecycle, speech opens nothing, + // so a closed channel there stays closed, and the words say what reaches + // the agent from a closed channel. + await registry.handleChanged('discord', { + added: [{ id: 'discord:g1:565656', type: 'discord', label: '#ungranted', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '565656' } }], + } as never); + setGrant(new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), [])); + routing.locus = ref('discord:g1:565656'); + const opensBeforeUngranted = opens.length; + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:565656', text: addressed }]); + assert.equal(opens.length, opensBeforeUngranted, 'the post opened nothing'); }); test('a conversation agent gets no note on its send: channel_open is refused to it', async () => { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 3c0aa5c9d..f2b622cc5 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -164,6 +164,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), inboundOnlyServer: () => undefined, @@ -227,6 +228,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), inboundOnlyServer: () => undefined, @@ -295,6 +297,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: (c: { serverId: string; channelId: string }) => { asked.push(`${c.serverId}/${c.channelId}`); return '@_reim0n'; }, @@ -316,6 +319,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, @@ -338,6 +342,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => undefined, inboundOnlyServer: () => undefined, @@ -374,6 +379,7 @@ describe('Trunk channel routing (item-3 redux)', () => { isChannelOpen: () => false, // A reply leaves this channel closed: the agent closed or declined it. speechOpens: (c: { serverId: string; channelId: string }) => { asked.push(`${c.serverId}/${c.channelId}`); return false; }, + getDesiredState: () => 'closed', getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => target, @@ -403,6 +409,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => '@_reim0n', @@ -441,6 +448,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getDescriptor: () => ({ label: 'Body · ears', direction: 'inbound' }), getChannelLabel: () => 'Body · ears', proseTargetFor: () => '#ears', @@ -489,6 +497,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, speechOpens: () => true, + getDesiredState: () => undefined, getDescriptor: (c: { channelId: string }) => ({ label: labels[c.channelId] }), getChannelLabel: (_serverId: string, id: string) => labels[id], inboundOnlyServer: () => undefined, From 56687897a0cb746f49087e727811369481c90d20 Mon Sep 17 00:00:00 2001 From: Cordelia-2434 Date: Sat, 24 Oct 2026 04:51:13 -0400 Subject: [PATCH 37/37] Agent close holds: a channel the server answered for another one takes no words as it stands A read of the previous commit showed that checking the failure a wait or an open saw covers only one order of events. Words a close leaves to go into a channel as it stands were still published by an id the server had just shown it resolves elsewhere where the agent's close came after the substituted open settled (a close's success clears the target's doubt and its answer names no channel), or where the words waited on the queue behind that open, since the wait keeps the substitution only as the last failure, which the close's success resets. So the doubt is now the channel's: answeredElsewhere marks a registration where main detects that an open was answered for another channel, an open answered for this id clears it, and publish() refuses words that would go into a marked channel without an open, whenever the close came, with "the "" server answered this channel's last open for a different channel, so words sent by its id may not reach it; once an open of it is answered for this channel, they go into it again". refusalGivesWay keeps only the convergence exception (convergeFailure, the cause chain walked), which is the failure's: a later operation that settles on the current target establishes it. Tests: the substituted open with the agent's close while it was out, after it settled, and with speech queued behind it; the mark cleared by an open answered for the channel; convergence run out while the words wait on the agent's close, and while they wait on their own open that the close overtook, which reaches the cause chain. The fragment says the same. Co-Authored-By: Claude Opus 5.5 --- changelog.d/agent-close-holds.breaking.md | 45 +++---- src/mcpl/channel-registry.ts | 56 ++++++--- test/channel-lifecycle-order.test.ts | 141 ++++++++++++++-------- 3 files changed, 157 insertions(+), 85 deletions(-) diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md index d9eb8dbf4..9827a871a 100644 --- a/changelog.d/agent-close-holds.breaking.md +++ b/changelog.d/agent-close-holds.breaking.md @@ -8,11 +8,12 @@ where speech used to reopen the channel. Words that wait on a channel's lifecycle work, or on an open they started, go into the channel as it stands once the agent closes or declines it meanwhile, or a module closes it, where they were refused with "a decision to close the channel - came before these words went out". They're still refused where the failure leaves in doubt which - channel the server reaches by that id: an open answered for another channel, or a channel whose - address kept changing until the tries ran out. Speech into any other closed channel, one nobody - decided about (such as a DM that woke the agent) or one a machine closed, opens it as before, - with that notice, and a close a machine decides while words wait still refuses them. + came before these words went out". They're still refused where the channel's address kept + changing until the tries ran out, and a channel whose last open the server answered for another + channel takes no words as it stands until an open of it is answered for this channel, since a + publish by its id may not reach it. Speech into any other closed channel, one nobody decided + about (such as a DM that woke the agent) or one a machine closed, opens it as before, with that + notice, and a close a machine decides while words wait still refuses them. - **Who needs to act:** an agent that closed a channel and wants its ongoing traffic again opens it with `channel_open`; speaking there no longer does. Messages that address the agent still arrive while a channel is closed. @@ -53,23 +54,23 @@ server's to say (#406): on discord-mcpl, the channel's later messages that don't address the agent don't arrive as they're posted, and anima-research/discord-mcpl#97 has discord-mcpl say so on its push. - - **What residents notice:** on discord-mcpl, opening a channel also turns on its reaction - forwarding, and closing it turns that off. A reply in a guild channel the agent closed used to - reopen it, and so turn its reactions on. Now reactions to that reply arrive only where the - agent opens the channel or turns them on with `set_reaction_visibility`. DMs are unaffected: - discord-mcpl requests no DM reaction events. discord-mcpl's `channels/open` also lifts a mute - set with `mute_channel`, so a reply that opens a muted channel nobody decided about, or one a - machine closed, unmutes it, as before; a channel the agent closed keeps its mute. On zulip_mcp, - an answer's topic comes from the last message delivered as `channels/incoming`, and a closed - stream's mentions are pushed instead, so in a stream the agent closed, every answer goes to the - topic of the stream's last message from before the close, where a reply used to reopen the - stream. - - **What hosts notice:** on discord-mcpl, a channel the agent closed keeps delivering the - messages that address the agent as push events, as any closed channel's are, where a reply used - to reopen it and switch them to `channels/incoming`. Host code that acts on - `channels/incoming`, such as connectome-host's outage reply, doesn't see them there. Nor does a - turn with no trigger, such as a heartbeat, come to speak there: where it speaks moves only with - `channels/incoming` messages. + - **What residents notice,** by our reading of discord-mcpl's and zulip_mcp's code: on + discord-mcpl, opening a channel also turns on its reaction forwarding, and closing it turns + that off. A reply in a guild channel the agent closed used to reopen it, and so turn its + reactions on. Now reactions to that reply arrive only where the agent opens the channel or + turns them on with `set_reaction_visibility`. DMs are unaffected: discord-mcpl requests no DM + reaction events. discord-mcpl's `channels/open` also lifts a mute set with `mute_channel`, so a + reply that opens a muted channel nobody decided about, or one a machine closed, unmutes it, as + before; a channel the agent closed keeps its mute. On zulip_mcp, an answer's topic comes from + the last message delivered as `channels/incoming`, and a closed stream's mentions are pushed + instead, so in a stream the agent closed, every answer goes to the topic of the stream's last + message from before the close, where a reply used to reopen the stream. + - **What hosts notice,** by our reading of discord-mcpl's and connectome-host's code: on + discord-mcpl, a channel the agent closed keeps delivering the messages that address the agent + as push events, as any closed channel's are, where a reply used to reopen it and switch them to + `channels/incoming`. Host code that acts on `channels/incoming`, such as connectome-host's + outage reply, doesn't see them there. Nor does a turn with no trigger, such as a heartbeat, + come to speak there: where it speaks moves only with `channels/incoming` messages. - **Hosts with modules that close channels:** a module's `channel_close` through its own tool call (`ctx.callTool`) without a machine source is now recorded with the source `module-tool`, where it was `agent-tool`. Speech leaves such a channel closed, as it does one the agent closed, but gives diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 83d234347..f6baf4e92 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -136,14 +136,15 @@ class ChannelLifecycleError extends Error { } /** Whether a lifecycle failure, or one it wraps (an overtaken open's - * `cause`), leaves in doubt which channel the server reaches by this id: - * convergence that never established the channel's target, or an answer - * for another channel. Words don't go into such a channel on the strength - * of a close the agent decided meanwhile (refusalGivesWay), since the - * server may not resolve their publish to it either. */ -function targetInDoubt(err: unknown): boolean { + * `cause`), is convergence that never established the channel's target. + * Words don't go into such a channel on the strength of a close the agent + * decided meanwhile (refusalGivesWay): the address they'd go to is the one + * that kept changing. A later operation that settles on the current target + * establishes it, so this is the failure's, not the channel's (compare + * answeredElsewhere). */ +function convergeFailure(err: unknown): boolean { for (let e: unknown = err; e instanceof Error; e = e.cause) { - if (e instanceof ChannelLifecycleError && (e.kind === 'converge' || e.kind === 'substituted')) return true; + if (e instanceof ChannelLifecycleError && e.kind === 'converge') return true; } return false; } @@ -1259,6 +1260,16 @@ export class ChannelRegistry { // Losing an open receipt does not confirm closure. Keep that distinction // private while preserving the public open:boolean availability projection. private unconfirmedChannelTargets = new WeakSet(); + // Registrations whose last open the server answered for another channel, + // until an open is answered for this one, or the server registers the + // channel again, which makes a new registration: a server that doesn't + // resolve this id may not resolve a publish by it either. A close's answer + // names no channel, so it doesn't clear the mark, and a retarget keeps the + // registration and its mark. Read where speech would put words into such a + // channel without an open (publish). Where the grant loses + // channels.lifecycle while the connection stays (§6.7), no open can clear + // the mark until the server reconnects and registers the channel again. + private answeredElsewhere = new WeakSet(); // Marked entries a close failed for since a receipt or traffic last // confirmed them, cleared where the mark is: a failed request's outcome is // unknown, so such a close may have closed the channel, where an open, @@ -3616,6 +3627,7 @@ export class ChannelRegistry { // its sends either. if (substitute !== undefined) { this.invalidateTransportConfirmation(entry); + this.answeredElsewhere.add(entry); throw new ChannelLifecycleError( `the "${serverId}" server answered for ` + (substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') + @@ -3625,6 +3637,7 @@ export class ChannelRegistry { } if (open) this.applyOpenDeclaration(entry, result); entry.open = open; + if (open) this.answeredElsewhere.delete(entry); this.unconfirmedChannelTargets.delete(entry); this.failedCloseTargets.delete(entry); const now = this.getDesiredState(serverId, channelId); @@ -4739,11 +4752,12 @@ export class ChannelRegistry { /** Whether words a wait or an open refused go into the channel after all: * its registration `now` is one speech leaves as it stands * (speechLeavesAsItStands), such as one the agent closed or declined - * while the words waited, and the failure doesn't leave in doubt which - * channel the server reaches by its id (targetInDoubt). A registration - * that went still fails them. */ + * while the words waited, and the failure isn't convergence that never + * established its target (convergeFailure). A registration that went + * still fails them, and publish() refuses one the server last answered + * for another channel (answeredElsewhere), whenever that happened. */ private refusalGivesWay(err: unknown, now: ChannelEntry | undefined): now is ChannelEntry { - return this.speechLeavesAsItStands(now) && !targetInDoubt(err); + return this.speechLeavesAsItStands(now) && !convergeFailure(err); } /** Whether this host doesn't direct the server's channel lifecycle: the @@ -4877,8 +4891,8 @@ export class ChannelRegistry { } catch (err) { // A channel the send now leaves as it stands, such as one the agent // closed or declined while queued work or the open was out, answers as - // one it found so: nothing failed that the send would have done. A - // failure that leaves its target in doubt is still reported + // one it found so: nothing failed that the send would have done. + // Convergence that never established its target is still reported // (refusalGivesWay). const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id }); if (this.refusalGivesWay(err, now)) return this.sendLeavesChannel(now); @@ -5756,8 +5770,9 @@ export class ChannelRegistry { // (speechOpens), and if it doesn't open, nothing goes out. A wait or an // open that fails is read against the channel as it is then, so a close // the agent decides while the words wait lets them go into the channel - // as that close leaves it, unless the failure leaves in doubt which - // channel the server reaches by its id (refusalGivesWay). + // as that close leaves it, unless the failure is convergence that never + // established its target (refusalGivesWay). A channel the server last + // answered for another one takes nothing as it stands (answeredElsewhere). // Lifecycle work already queued for the channel settles first: words go // out only into the channel it leaves, and fail visibly if it leaves @@ -5846,6 +5861,17 @@ export class ChannelRegistry { if (isInboundOnly(current)) { return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() }; } + // A channel the words go into as it stands, with no open to check what + // the server reaches by its id, takes nothing where the server answered + // its last open for another channel, whenever that was (answeredElsewhere): + // the words could reach that other channel. + if ((!current.open || this.closeStands(current)) && this.answeredElsewhere.has(current)) { + return { + status: 'failed', destination, at: at(), + reason: `the "${current.serverId}" server answered this channel's last open for a different channel, so words ` + + "sent by its id may not reach it; once an open of it is answered for this channel, they go into it again", + }; + } // A grant reduced during any of the waits holds at once (§6.7): the // words already on their way take it too. Its refusal is worded for the // connection that refused (sendRefusal's `checked`). diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 668c35291..9875fcf89 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -701,64 +701,109 @@ test("an open answered for another channel applies nothing of its answer to the assert.equal(f.registry.inboundOnlyServer({ serverId: 'test', channelId: 'x' }), undefined, "the other channel's declaration isn't applied"); }); -for (const via of ['speech', 'a send'] as const) { - test(`${via} whose open was answered for another channel isn't let through by the agent's close made while that open was out`, async () => { - // An answer for another channel leaves in doubt which channel the server - // reaches by this id, and a publish by that id may not reach it either. - // A close the agent decides while the open is out lets words go into the - // channel as it stands only where the failure says nothing of the target. - const f = fixture(); - await f.registry.handleChanged('test', { added: [descriptor('x'), descriptor('y')] }); - f.answeredFor.set('x', 'y'); - f.held.add('x'); - const outcome = via === 'speech' - ? f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }) - : f.registry.openIfClosedForSend('x', 'test'); - await tick(); - assert.equal(f.pending.length, 1, 'the open is in flight'); - const close = f.tool('close'); - await f.drain(outcome, close); - assert.equal((await close).success, true); - assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); - if (via === 'speech') { - assert.equal(await outcome, null); - assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); - assert.match(f.routeFailures[0]?.reason ?? '', /answered for a different channel/); - } else { - assert.equal(((await outcome) as { status: string }).status, 'open-failed', "not answered as a channel the send left closed"); - } - }); -} +// An open the server answered for another channel leaves in doubt which +// channel it reaches by this id, and a publish by that id may not reach it +// either. Words that would go into the channel as it stands, with no open to +// check, aren't published while that doubt stands, whenever the agent's +// close came: while that open was out, after it, or with the words waiting +// on the queue behind it. An open answered for this channel ends the doubt. +const answeredElsewhere = /^the "test" server answered this channel's last open for a different channel, so words sent by its id may not reach it; once an open of it is answered for this channel, they go into it again$/; -test("words for a channel the agent closed aren't let through where convergence never established the channel's target", async () => { - // The words wait on the agent's close, retargeted before each receipt until - // its tries run out. The close stands, but the server's channel for this id - // was never established, so the words aren't published into it. +test("speech whose open was answered for another channel isn't let through by the agent's close made while that open was out", async () => { const f = fixture(); - await f.registry.handleChanged('test', { added: [descriptor('x')] }); - assert.equal((await f.tool('open')).success, true); + await f.registry.handleChanged('test', { added: [descriptor('x'), descriptor('y')] }); + f.answeredFor.set('x', 'y'); f.held.add('x'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + await tick(); + assert.equal(f.pending.length, 1, "speech's open is in flight"); const close = f.tool('close'); + await f.drain(speech, close); + assert.equal((await close).success, true); + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); + assert.equal(await speech, null); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.match(f.routeFailures[0]?.reason ?? '', answeredElsewhere); +}); + +test("a channel whose open was answered for another channel takes no words as it stands after the agent's close, until an open is answered for it", async () => { + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x'), descriptor('y')] }); + f.answeredFor.set('x', 'y'); + assert.equal((await f.tool('open')).success, false); + assert.equal((await f.tool('close')).success, true, "the agent's close, after the open settled"); + assert.equal(await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }), null); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.match(f.routeFailures[0]?.reason ?? '', answeredElsewhere); + // Once the server answers an open of x for x, words go into it as it stands again. + f.answeredFor.delete('x'); + assert.equal((await f.tool('open')).success, true); + assert.equal((await f.tool('close')).success, true); + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); + assert.equal(f.publishedWhileOpen.length, 1); +}); + +test("speech waiting behind an open answered for another channel, and the agent's close after it, isn't published", async () => { + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x'), descriptor('y')] }); + f.answeredFor.set('x', 'y'); + f.held.add('x'); + const open = f.tool('open'); await tick(); - assert.equal(f.pending.length, 1, 'the close is in flight'); + assert.equal(f.pending.length, 1, "the agent's open is in flight"); + const close = f.tool('close'); const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); - try { - for (let i = 0; i < 12 && f.pending.length > 0; i++) { - await f.registry.handleChanged('test', { updated: [descriptor('x', false, 'churn-' + i)] }); - f.pending.shift()!.finish(); - await tick(); - } - } finally { - f.held.delete('x'); - while (f.pending.length > 0) f.pending.shift()!.finish(); - } + await f.drain(open, close, speech); + assert.equal((await open).success, false); + assert.equal((await close).success, true); assert.equal(await speech, null); assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); - assert.match(f.routeFailures[0]?.reason ?? '', /kept changing, and 5 tries didn't settle it/); - assert.equal((await close).success, false); - assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); + assert.match(f.routeFailures[0]?.reason ?? '', answeredElsewhere); }); +for (const waits of ["the agent's close", 'their own open'] as const) { + test(`words for a channel the agent closed aren't let through where convergence never established its target, waiting on ${waits}`, async () => { + // The work the words wait on is retargeted before each receipt until its + // tries run out. The agent's close stands, but the server's channel for + // this id was never established, so the words aren't published into it: + // waiting on the close, the first wait meets that failure itself; waiting + // on their own open, which the close overtook, it's the open's cause. + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x')] }); + let close: Promise<{ success: boolean }> | undefined; + let speech: Promise; + if (waits === "the agent's close") { + assert.equal((await f.tool('open')).success, true); + f.held.add('x'); + close = f.tool('close'); + await tick(); + assert.equal(f.pending.length, 1, 'the close is in flight'); + speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + } else { + f.held.add('x'); + speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + await tick(); + assert.equal(f.pending.length, 1, "the words' open is in flight"); + close = f.tool('close'); + } + try { + for (let i = 0; i < 12 && f.pending.length > 0; i++) { + await f.registry.handleChanged('test', { updated: [descriptor('x', false, 'churn-' + i)] }); + f.pending.shift()!.finish(); + await tick(); + } + } finally { + f.held.delete('x'); + while (f.pending.length > 0) f.pending.shift()!.finish(); + } + assert.equal(await speech, null); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.match(f.routeFailures[0]?.reason ?? '', /kept changing, and 5 tries didn't settle it/); + await close; + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', "the agent's close stands"); + }); +} + test("an open's answer for a target the channel has since left applies nothing; the new target's answer decides", async () => { const f = fixture(); f.declares.set('original', 'inbound');