diff --git a/changelog.d/agent-close-holds.breaking.md b/changelog.d/agent-close-holds.breaking.md new file mode 100644 index 000000000..9827a871a --- /dev/null +++ b/changelog.d/agent-close-holds.breaking.md @@ -0,0 +1,97 @@ +- **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`), plain speech, or a draft's resend 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 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". 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. + - **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, 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 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 + opening it: "To reply without opening it, decline it first (option 3): a channel you've + 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,** 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 + 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 + again (connectome-host's `channel_mode` with `mentions`) records the close as the module's. +- **Direct `ChannelRegistry` consumers:** `openIfClosedForSend()` answers a new `status`, + `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, 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 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 new file mode 100644 index 000000000..a338c4c4c --- /dev/null +++ b/changelog.d/agent-close-holds.fixed.md @@ -0,0 +1,28 @@ +- 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 + 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 + module's close over the agent's, now record themselves. Before, a channel the agent had already + 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. +- `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", 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 + send tool that can address the channel, and says what replying there does. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index b8e742880..c3a34256f 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -99,9 +99,18 @@ depends on the route: 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 channel decided closed opens it, with the usual `[channels]` notice. +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 or a module closed it, or this host doesn't direct its +server's lifecycle: 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 1e62c4120..188498cf3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -507,8 +507,11 @@ function inboundOnlySpeechNote(serverId: string): string { /** * 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. + * 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', @@ -6290,6 +6293,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; @@ -6400,6 +6408,195 @@ 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 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'], + 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), + }; + } + + /** 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 + * the module's own tool is the way back. Every other closed channel opens + * on speech where this host directs its server's lifecycle, as it always + * 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. + * + * 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 = 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 + * out, or undefined where nothing needs saying. A send keeps the tune-out + * (ChannelRegistry.openChannelNow), so the agent that sent there expecting + * answers learns they still go to its subconscious. A send tool's answer + * carries 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. */ + private tunedOutSendNote(agentName: string, channel: ChannelRef): string | undefined { + try { + const registry = this.channelRegistry; + 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.${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, 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 + * 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, channels: Iterable): string[] { + try { + if (this.conversationAgentHomes.has(agentName)) return []; + 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) { + console.error(`[closed-channel-note] for ${agentName} failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } + } + + /** + * A send tool's answer, with the closed-channel note where its words + * stayed in a channel the agent closed or declined itself, or the tune-out + * line where they went to one it tuned out, after the server's own words: + * one more text block where the answer is a list of blocks, or a paragraph + * where it's text. A code_execution script's send (`forScript`) keeps the + * server's answer alone, since the script parses it, and the agent whose + * script sent is told in its own window instead, as a notice. The channel + * is the one the send went to on its server (resolveSendTarget), from the + * id the send's answer or its call named. The note is advice: if working it + * out fails, or the window can't take the notice, that's logged, and the + * answer stays as it was. + */ + private noteSendIntoKeptChannel( + agentName: string, + toolName: string, + target: string, + serverId: string, + answer: ToolResult, + forScript: boolean, + ): ToolResult { + let channel: ChannelRef | undefined; + try { + channel = this.channelRegistry?.resolveSendTarget(target, serverId); + } catch (err) { + console.error(`[closed-channel-note] for ${agentName} failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (!channel) return answer; + const [closedNote] = this.closedChannelNotes(agentName, [channel]); + const tunedOutNote = closedNote ? undefined : this.tunedOutSendNote(agentName, channel); + const body = closedNote ?? tunedOutNote; + if (!body) return answer; + const tag = closedNote ? '[closed channel]' : '[tuned out]'; + if (forScript) { + const window = this.noticeTargetFor(agentName, 'closed-channel'); + try { + if (window) { + this.addMessage('user', [{ + type: 'text', + text: `${tag} Your script's ${toolName}: ${body}${this.closedChannelNoteOff(agentName)}`, + }], { system: true, kind: 'channel-notice' } as MessageMetadata, window); + } + } catch (err) { + console.error(`[closed-channel-note] for ${agentName}'s script failed: ${err instanceof Error ? err.message : String(err)}`); + } + return answer; + } + 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; + return { ...answer, data }; + } + /** 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 @@ -13745,14 +13942,22 @@ export class AgentFramework { * only that messages addressing the agent reach it whether or not it opens * the channel: it is shown only when one has arrived from a closed channel, * and both servers deliver them either way. What else arrives is for the - * server to say on its push. Option 1 says a reply opens the channel, - * because routeSpeech opens a closed channel before delivering into it. Its - * old follow-up warning assumed the channel stays closed after the reply, - * which that open makes false (and on discord-mcpl, channels/open also - * subscribes). When 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. + * server to say on its push. Option 1 says what a reply does, which + * speechOpens decides: a reply opens a closed channel nobody decided to keep + * closed, and leaves one the agent closed or declined itself closed. What + * else reaches the agent from a channel left closed is its server's to say + * on its push, so the option says nothing about it. When 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 open. 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. */ private buildClosedChannelInvitation(opts: { serverId: string; @@ -13769,6 +13974,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; const channel: ChannelRef = { serverId: opts.serverId, channelId: opts.channelId }; if (this.channelRegistry.isChannelOpen(channel)) return null; const descriptor = this.channelRegistry.getDescriptor(channel); @@ -13792,12 +13998,32 @@ export class AgentFramework { ? namer.proseTargetFor(channel) : [hashLabel, opts.channelId].find((t): t is string => !!t && isProseTargetToken(t)); const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(channel); + // 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 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 = + 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 - ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, which opens the channel.\n` - : `1. Replying with a ">>" prefix isn't available here: no single-word target reaches this channel unambiguously. Open it (option 2) to reply.\n`; + ? replyOpens + ? `1. Reply — write your reply this turn prefixed with ">>${target}"; it will be delivered there, which opens ` + + `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 leaves the channel closed.\n`); const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -16220,11 +16446,13 @@ export class AgentFramework { routing.suppressed = 0; const seen = new Set(); const shown: string[] = []; + const landed: ChannelRef[] = []; for (const channel of list ?? []) { const key = destinationKey(channel); if (seen.has(key)) continue; seen.add(key); shown.push(this.channelShown(channel)); + landed.push(channel); } const notes: string[] = []; // Each of the turn's drafts as it stands now: a mid-turn notice lets the @@ -16300,6 +16528,12 @@ export class AgentFramework { // since the turn held it may have had such an attempt: its state is // unknown now. const uncertain = unconfirmed.length > 0 || dismissedAtRisk.length > 0 || letGo.length > 0; + // Plain speech that stayed in a channel the agent closed or declined says + // so, unless the agent has turned the note off. + const closedNotes = this.closedChannelNotes(agent.name, landed); + const closedNote = closedNotes.length > 0 + ? ` · ${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 // the turn's stream is for the host to know, and an explicit send @@ -16307,7 +16541,7 @@ export class AgentFramework { // it is never uncertain. const text = shown.length > 0 - ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` + ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}${closedNote}` : agent.proseRouting === 'disabled' ? `[delivered] plain speech → no channel — ${suppressedNote}` : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`; @@ -16327,6 +16561,41 @@ export class AgentFramework { } } + /** + * 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 + * 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 seen = new Set(); + const landed = routing.deliveries.filter((channel) => { + const key = destinationKey(channel); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + routing.deliveries = []; + const notes = this.closedChannelNotes(agent.name, landed); + if (notes.length === 0) return; + const target = this.noticeTargetFor(agent.name, 'closed-channel'); + if (!target) return; + try { + this.addMessage( + 'user', + [{ type: 'text', text: `[closed channel] ${notes.join(' ')}${this.closedChannelNoteOff(agent.name)}` }], + { system: true, kind: 'delivery-receipt' } as MessageMetadata, + target, + ); + } 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 @@ -16631,8 +16900,9 @@ export class AgentFramework { * when the server next registers the channel), or an open that the agent's * own delivery or reply caused (`opened-by-delivery`, `opened-by-reply`), * once its transport is confirmed. It names `channel_close` as the opt-out - * (neither the subscription policy nor the server's default reopens a - * channel the agent closed) only where the agent it's addressed to can + * (neither the agent's own speech there, the subscription policy nor the + * server's default reopens a channel the agent closed: speechOpens and + * ensureInitialDesiredState) only where the agent it's addressed to can * call it for every channel it names (canCloseChannels): a conversation * fork may close only its home channel, and an agent's allowedTools can leave * channel_close out. The other residents can read it too, where several @@ -16712,8 +16982,9 @@ export class AgentFramework { const optOut = (holds: boolean, canClose: boolean) => (holds ? ' What an open channel brings you is up to its server: some send every message in it, and others only ' + "what addresses you unless you're subscribed to it; their own tools can change that." : '') + - (canClose ? ` If you don't want ${plural ? 'one of them' : 'it'}, use channel_close: neither the subscription policy ` + - "nor its server's own default opens it again." : ''); + (canClose ? ` 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, the subscription policy nor its server's own " + + 'default opens it again.' : ''); // An open caused by an agent's own send is that agent's to hear about, in // the window it reads; a policy admission with no agent behind it goes to // the primary. An agent that has ended (a run whose final reply is routed @@ -17723,6 +17994,8 @@ export class AgentFramework { delivered = outcome.status === 'delivered'; if (delivered) { this.proseBounceStreaks.delete(name); + // For the turn's closed-channel note (appendClosedChannelNoteAlone). + this.recordProseDelivery(routing, { delivered: true, ...outcome.destination! }); round.onPublished?.(outcome.destination!, body, outcome.messageId); } else if (outcome.status === 'failed') { // Unknown deliveries are marker-only: offering a fresh draft would @@ -19562,6 +19835,10 @@ 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 its + // closed-channel 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 @@ -20580,9 +20857,12 @@ export class AgentFramework { // and the continuation see the last rounds' deliveries; // trailing/text-only segments were awaited in-loop. Every mode // but explicit, whose envelopes acknowledge themselves through - // the prose gateway. + // the prose gateway, and whose turn ends with the closed-channel + // note alone, where one is due. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent, streamRouting); + } else if (!trigger?.suppressProse) { + this.appendClosedChannelNoteAlone(agent, streamRouting); } // A `!` continuation this turn asked for. This gives prose the @@ -29071,7 +29351,8 @@ export class AgentFramework { } return { result, toolResult: await reading(result, mcplPeer) }; }) - .then(({ result, toolResult }) => { + .then(({ result, toolResult: answer }) => { + let toolResult = answer; const durationMs = Date.now() - startTime; this.traceMcpToolOutcome(serverId, toolName, call.id, toolResult, durationMs); @@ -29089,7 +29370,10 @@ export class AgentFramework { // channel the registry knows as closed drives the open flow // channel_open uses (the server's channels/open), so posting there // doesn't leave the channel half-alive (no typing, no reactions, no - // inbound forwarding). If that open fails, the channel stays as it + // inbound forwarding), unless the agent closed or declined it + // itself, or a module closed it (ChannelRegistry.speechOpens): then + // it stays closed, and the send's answer says so where the agent's + // own decision kept it. If that open fails, the channel stays as it // was, and where the host directs the server's lifecycle the agent // is told, with how to try again. // Fire-and-forget: the send already succeeded; the open must not @@ -29102,6 +29386,12 @@ export class AgentFramework { const target = extractChannelIdFromToolResult(result.content) ?? (typeof args.channelId === 'string' ? (args.channelId as string) : undefined); + // Where the agent's words stayed in a channel it closed or declined + // itself, or tuned out, the send's answer says so, read before any + // open. A code_execution script's send keeps the server's answer + // alone, since the script parses it, and the agent whose script + // sent is told in its own window instead, as a notice. + if (target) toolResult = this.noteSendIntoKeptChannel(agentName, toolName, target, serverId, toolResult, forScript); if (target) { // Whether the host directs the server's lifecycle is read before // the open, not after its failure: a transport that drops while the @@ -29872,7 +30162,29 @@ export class AgentFramework { // made outside the turn (callingTurnOf), such as a script's left running // or one an earlier turn started, speaks through none of its routing. 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 registry = this.channelRegistry; + // The default words, for the channel they go to. Speech leaves a + // 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.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)}`); + } + return `💤 Going quiet for ${human}. I'll still see messages${staysClosed ? ' addressed to me' : ''}, but won't respond until I wake.`; + }; + const announcement = (channel: ChannelRef | null): string => input.message ?? defaultWords(channel); const agent = this.agents.get(agentName); const callingTurn = this.callingTurnOf(agentName, call); // The latest start's trigger is the calling turn's only where the @@ -29902,7 +30214,7 @@ export class AgentFramework { // model has set one; otherwise stay quiet — never guess a channel. const target = callingTurn.pin; if (target) { - this.channelRegistry.routeSpeech(agentName, text, target).then(noted).catch((err) => { + registry.routeSpeech(agentName, announcement(target), target).then(noted).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); } else { @@ -29910,7 +30222,7 @@ export class AgentFramework { } } else { const locus = callingTurn.locus ?? null; - this.channelRegistry.routeSpeech(agentName, text, locus).then(noted).catch((err) => { + registry.routeSpeech(agentName, announcement(locus), locus).then(noted).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4b243369a..f6baf4e92 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -135,6 +135,20 @@ class ChannelLifecycleError extends Error { } } +/** Whether a lifecycle failure, or one it wraps (an overtaken open's + * `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') 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. */ @@ -537,7 +551,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ name: 'channel_close', description: 'Close a channel. Messages that address you still reach you; what else stops reaching you ' + - 'depends on its server.', + 'depends on its server. You can still speak there. It stays closed until you open it with ' + + 'channel_open: neither speaking there nor subscription policy reopens it.', inputSchema: { type: 'object' as const, properties: { @@ -1081,6 +1096,21 @@ export type ChannelToolOrigin = * not arbitrary self-description. */ export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']); +/** 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, as it does a + * channel the agent closed (speechOpens), 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'; + +/** Whether a closed record is a close the agent or a module decided + * (channel_close, channel_decline, or a module's own close), which speech + * never undoes. */ +function isCloseDecision(source: string): boolean { + return AGENT_DECISION_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE; +} + export class ChannelRegistry { private serverRegistry: McplServerRegistry; private featureSetManager: FeatureSetManager; @@ -1230,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, @@ -1264,7 +1304,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); @@ -2934,17 +2974,30 @@ export class ChannelRegistry { ): void { const key = this.lifecycleKey(serverId, channelId); const current = this.desiredStates.get(key); + // 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). + const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source; // A decision that records the state already on record changes nothing, - // so it writes nothing, unless it's the agent's own (channel_open, - // channel_close or channel_decline) and the record isn't. The record - // then says the agent decided, which is all handleToolClose's - // explicit-open guard and ensureInitialDesiredState's lift of a default - // ask of it. openChannelNow depends on it too: a failed open by speech - // or a reply tells a decision made during its round-trip by the record - // no longer being the object it wrote, and the agent's channel_open, - // the only decision of the same state that can be made then, gets a new - // record here. - if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) { + // so it writes nothing, with two exceptions. The agent's own decision + // (channel_open, channel_close or channel_decline) records itself over a + // record that isn't the agent's. The record then says the agent decided, + // which is all handleToolClose's explicit-open guard and + // ensureInitialDesiredState's lift of a default ask of it. openChannelNow + // depends on it too: a failed open by speech or a reply tells a decision + // made during its round-trip by the record no longer being the object it + // wrote, and the agent's channel_open, the only decision of the same + // state that can be made then, gets a new record here. And the latest + // deliberate close wins: the agent's close or decline, or a module's + // 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 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) { return; } this.appendLifecycleEvent({ @@ -2952,10 +3005,10 @@ export class ChannelRegistry { serverId, channelId, desired, - source, + source: recorded, timestamp: new Date().toISOString(), }); - this.desiredStates.set(key, { state: desired, source }); + this.desiredStates.set(key, { state: desired, source: recorded }); } // ========================================================================== @@ -3574,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') + @@ -3583,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); @@ -3900,6 +3955,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 @@ -4645,13 +4707,132 @@ export class ChannelRegistry { return undefined; } + /** Whether the decision on record is a close the agent or a module made + * (isCloseDecision). */ + private closeDecided(ref: ChannelRef): boolean { + const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId)); + return current?.state === 'closed' && isCloseDecision(current.source); + } + + /** + * Whether the agent's words delivered into this channel open it first, by + * publish() or after an explicit send (openIfClosedForSend): one that isn't + * open, or whose close stands (closeStands: a close that failed may have + * left its transport open), unless speech leaves it as it stands + * (speechLeavesAsItStands). Where nobody decided (no record, a pure + * default) or a machine closed the channel (subscription-gc, + * housekeeping), it opens, as it always did, and so does one decided open + * whose transport isn't confirmed. A tuned-out channel whose transport + * isn't open is opened for its traffic, and its tune-out stays + * (openChannelNow). A server that isn't up isn't one this host can't + * direct, though a drop empties its grant until its next exchange, so + * speech there is refused as it always was: by the open's failure, which + * says the server is connecting or reconnecting, or, where its grant is + * empty, by publish()'s grant check before any open. + */ + speechOpens(ref: ChannelRef): boolean { + const entry = this.entryAt(ref); + return !!entry && (!entry.open || this.closeStands(entry)) && !this.speechLeavesAsItStands(entry); + } + + /** Whether speech leaves this registration as it stands, never opening + * it, and words go into it as it is: the decision on record is a close + * 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 (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, 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.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 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) && !convergeFailure(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.speechLeavesAsItStands(entry); + } + /** - * 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). An `open-failed` answer says + * 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'; + if (current.source === 'invitation-declined') return 'declined'; + return undefined; + } + + /** The registered channels an explicit send tool may have delivered into, + * 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. Matches on `serverId` when it is given, + * across every server otherwise. */ + private sendTargets(rawChannelId: string, serverId?: string): ChannelEntry[] { + const matches = [...this.channels.values()].filter( + (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId), + ); + if (matches.length > 0) return matches; + return [...this.channels.values()].filter( + (e) => + (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId && + (!serverId || e.serverId === serverId), + ); + } + + /** The one registered channel an explicit send tool delivered into + * (sendTargets), or undefined when none or more than one matches. A + * lookup only, for what's said beside the send's answer before it's + * queued; openIfClosedForSend decides whether the send opens it. */ + resolveSendTarget(rawChannelId: string, serverId?: string): ChannelRef | undefined { + const matches = this.sendTargets(rawChannelId, serverId); + return matches.length === 1 ? refOf(matches[0]!) : undefined; + } + + /** + * Open a channel because something was DELIVERED into it by an explicit + * send tool, so typing indicators, reaction machinery and inbound + * forwarding all come alive with the first outbound message, unless + * speech leaves it as it stands (speechLeavesAsItStands: the agent closed + * or declined it itself, a module closed it, or its server is up and this + * host doesn't direct its lifecycle). Such a channel opens nothing, and + * answers `stays-closed`, or `already-open` where its transport is open + * with no close standing, whether the send found it so or it became so + * while the send's wait or open was out. + * Accepts composite MCPL ids or raw server-internal ids (sendTargets). An + * `open-failed` answer says * whether the open that failed was one that would have opened a channel * not selected open (`opening`), as opposed to one reconfirming the * transport of a channel already selected open or tuned out. It isn't one @@ -4667,7 +4848,7 @@ export class ChannelRegistry { rawChannelId: string, serverId?: string, ): Promise<{ - status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed'; + status: 'opened' | 'already-open' | 'stays-closed' | 'unknown-channel' | 'ambiguous' | 'open-failed'; channelId?: string; /** The channel's server, beside `channelId` whenever it names one. */ serverId?: string; @@ -4675,16 +4856,7 @@ export class ChannelRegistry { opening?: boolean; selectedForOpening?: SelectedForOpening; }> { - let matches = [...this.channels.values()].filter( - (e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId), - ); - if (matches.length === 0) { - matches = [...this.channels.values()].filter( - (e) => - (e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId && - (!serverId || e.serverId === serverId), - ); - } + const matches = this.sendTargets(rawChannelId, serverId); if (matches.length === 0) return { status: 'unknown-channel' }; if (matches.length > 1) return { status: 'ambiguous' }; let entry = matches[0]!; @@ -4697,6 +4869,11 @@ export class ChannelRegistry { if (entry.open && (desired === 'open' || desired === 'tuned-out')) { return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }; } + // A channel speech leaves as it stands opens nothing. Every other one + // is opened, as it always was: one open at the transport with no + // decision on record too, so the send's engagement is recorded as an + // open decision, which a retarget or a restart carries out. + if (this.speechLeavesAsItStands(entry)) return this.sendLeavesChannel(entry); opening = desired !== 'open' && desired !== 'tuned-out'; const { opened } = await this.openChannelNow(entry, 'opened-by-reply'); entry = await this.waitForOpenChannel(entry); @@ -4712,9 +4889,16 @@ export class ChannelRegistry { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label, }; } 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. + // 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); // Where a decision made during the open's round-trip replaced it, such - // as the agent's own close or channel_open, the channel is as that - // decision left it, and the agent isn't told the open failed. + // as the agent's channel_open, the channel is as that decision left it, + // and the agent isn't told the open failed. if (err instanceof ChannelLifecycleError && err.kind === 'overtaken') opening = false; this.emitTraceFn({ type: 'mcpl:channel-open-failed', @@ -4732,6 +4916,15 @@ export class ChannelRegistry { } } + /** 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 } { + const status = entry.open && !this.closeStands(entry) ? 'already-open' : 'stays-closed'; + return { status, channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }; + } + private async handleToolOpen(input: { channelId: string; serverId?: string; @@ -4822,7 +5015,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 @@ -4850,11 +5044,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 { @@ -4894,7 +5089,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. @@ -4975,7 +5170,12 @@ export class ChannelRegistry { if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { return this.tunedOutRefusal('channel_decline', entry); } - if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') { + // Refused only for a channel open at the transport. One decided open + // whose open failed or is still on its way isn't open there, and the + // invitation a message from it brings offers the decline as the way to + // reply without opening it. The decline closes it below, through the + // lifecycle queue, wherever its transport may be open. + if (entry.open) { // channel_close is the call that closes it: its own answer says when, // as for a server that isn't up. A server that's up without that grant // keeps its channels as it chooses, so the text says so, and whom to @@ -4984,7 +5184,7 @@ export class ChannelRegistry { ? this.lifecycleRefusal(entry.serverId) : undefined; return { success: false, - error: `Channel ${quoted} is open, or set to open, so there's nothing to decline` + + error: `Channel ${quoted} is open, so there's nothing to decline` + (refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.` : '. If you need it closed, call channel_close.'), isError: true, @@ -5479,11 +5679,14 @@ export class ChannelRegistry { * refused, never routed through whichever server came first). An omitted `serverId` means "not * in use"; a supplied one must be a non-empty server id, and an empty or * non-string selector is refused rather than read as omission. It is not - * possible to send into a closed channel: a closed but registered - * destination is 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. + * possible to send into a closed channel that speech opens (speechOpens): + * 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 (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 @@ -5559,30 +5762,41 @@ export class ChannelRegistry { status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(), }); + // 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 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 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 - // none open. + // none open and speech would open it. if (this.hasPendingLifecycle(entry)) { try { entry = await this.waitForOpenChannel(entry); } catch (err) { - return prevented(err); + const now = this.entryAt(refOf(entry)); + if (!this.refusalGivesWay(err, now)) return prevented(err); + entry = now; } } - // INVARIANT: it is not possible to send into a closed channel. A send - // into a closed-but-registered channel (the canonical case: a DM that - // woke the agent, since DMs register closed) OPENS it first, so typing - // indicators, reaction machinery and inbound forwarding come alive with - // the words. If the open fails, nothing goes out: a half-alive delivery - // is worse than a loud marker. A channel decided closed is engaged even - // while its transport is still open (a close that failed), as a reply - // would engage it, wherever an open can succeed. Without the lifecycle - // grant it can't, and a send opens before it publishes, so there a - // channel whose transport is open keeps publishing. - const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' && - CapabilityGrant.of(server).has('channels.lifecycle'); - if (!entry.open || engagesDecidedClosed) { + // A send into a closed-but-registered channel (the canonical case: a DM + // that woke the agent, since DMs register closed) OPENS it first where + // speech opens it (speechOpens), so typing indicators, reaction + // machinery and inbound forwarding come alive with the words. If the + // open fails, nothing goes out: a half-alive delivery is worse than a + // loud marker. A channel decided closed is engaged even while its + // transport is still open (a close that failed), as a reply would engage + // it, wherever an open can succeed, unless that close is the agent's or + // a module's own. + if (this.speechOpens(refOf(entry))) { try { const { opened } = await this.openChannelNow(entry, openSource); entry = await this.waitForOpenChannel(entry); @@ -5611,7 +5825,9 @@ export class ChannelRegistry { } } } catch (err) { - return { status: 'failed', destination, reason: openFailedReason(err), at: at() }; + const now = this.entryAt(refOf(entry)); + if (!this.refusalGivesWay(err, now)) return { status: 'failed', destination, reason: openFailedReason(err), at: at() }; + entry = now; } } @@ -5628,14 +5844,16 @@ export class ChannelRegistry { // its close standing. The loop opens nothing itself. The check then runs // 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. + // 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. 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))) { + while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) { try { entry = await this.waitForOpenChannel(current); } catch (err) { - return prevented(err); + if (!this.refusalGivesWay(err, this.channels.get(key))) return prevented(err); } current = this.channels.get(key); } @@ -5643,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/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-agent-words.test.ts b/test/channel-agent-words.test.ts index 5b6fcd419..c26a321fb 100644 --- a/test/channel-agent-words.test.ts +++ b/test/channel-agent-words.test.ts @@ -463,6 +463,8 @@ test("publishForAgent's reason for an inbound-only channel is inbound-only, what test('speech that has to open its channel says why the open didn\'t happen, and what helps where it was refused', async () => { // A grant can let words out without letting this host open the channel: // here channels.publish without channels.lifecycle, for a channel not open. + // While the server is up, nothing opens there, so the words go into the + // channel as it stands. const f = await fixture({ a: ['x'] }); f.servers.set('a', up(['channels.lifecycle'])); const failed = async () => { @@ -470,8 +472,9 @@ test('speech that has to open its channel says why the open didn\'t happen, and assert.equal(await f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }), null); return f.failures.map((failure) => failure.reason); }; - assert.deepEqual(await failed(), ['opening the channel for these words didn\'t complete: this host isn\'t permitted to open or ' + - 'close the "a" server\'s channels; if you need it, ask whoever runs this host']); + assert.deepEqual(await f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }), + { delivered: true, serverId: 'a', channelId: 'x' }); + assert.deepEqual(f.failures, []); f.connecting.add('a'); assert.deepEqual(await failed(), ['opening the channel for these words didn\'t complete: this host is connecting to the "a" ' + 'server, so try again in a moment']); @@ -591,12 +594,12 @@ test('channel_decline says the decline is recorded, and what became of its ackno server.sendChannelsClose = async () => ({}); await f.call('channel_open', { channelId: 'x' }); assert.equal((await f.call('channel_decline', { channelId: 'x', messageId: 'm3' })).error, - 'Channel "x" is open, or set to open, so there\'s nothing to decline. If you need it closed, call channel_close.'); + 'Channel "x" is open, so there\'s nothing to decline. If you need it closed, call channel_close.'); // A server that isn't up has an empty grant until its next exchange, and // channel_close closes the channel once it's back, so it's still named. f.servers.set('a', down(true)); assert.equal((await f.call('channel_decline', { channelId: 'x', messageId: 'm4' })).error, - 'Channel "x" is open, or set to open, so there\'s nothing to decline. If you need it closed, call channel_close.'); + 'Channel "x" is open, so there\'s nothing to decline. If you need it closed, call channel_close.'); }); test('a decline whose close a later open overtook says that decision stands', async () => { @@ -789,7 +792,7 @@ test('a decline on a server whose channels this host can\'t open or close says i // And another decline is refused, with no pointer to a channel_close that // couldn't close it here. assert.equal((await f.call('channel_decline', { channelId: 'y', messageId: 'm4' })).error, - 'Channel "y" is open, or set to open, so there\'s nothing to decline, and this host isn\'t permitted to open or close ' + + 'Channel "y" is open, so there\'s nothing to decline, and this host isn\'t permitted to open or close ' + 'the "a" server\'s channels; if you need it closed, ask whoever runs this host.'); }); @@ -1249,7 +1252,7 @@ test('a decline whose registration came back, with only work a removal cancels s assert.deepEqual(closes, ['y']); }); -test('words whose open completed, then met a close decided while later work held them, say the close', async () => { +test("words whose open completed, then met a machine's close decided while later work held them, say the close", async () => { // The open step's wait joins lifecycle work queued after the open, here a // backscroll's, so a close decided meanwhile is what the words met. Their // own open did complete, so nothing says it didn't. @@ -1267,7 +1270,10 @@ test('words whose open completed, then met a close decided while later work held const backscroll = f.call('channel_open', { channelId: 'x', backscroll: 5 }); answers.shift()!(); await sent(1); - const closing = f.call('channel_close', { channelId: 'x' }); + // A machine's close: the agent's own would let the words go into the + // channel as it leaves it. + const closing = f.registry.handleChannelToolCall('channel_close', { channelId: 'x', source: 'subscription-gc', overrideExplicitOpen: true }, + { kind: 'module', moduleName: 'subscription-gc' }); answers.shift()!(); assert.equal(await speech, null); assert.deepEqual(f.failures.map((failure) => failure.reason), ['a decision to close the channel came before these words went out']); diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 526e9f43f..9875fcf89 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -85,6 +85,12 @@ function fixture(onNotice?: NoticeHandler, store?: JsStore, coalesce?: RegistryO ); const isOpen = (id = 'x') => registry.getOpenChannels().some((e) => e.descriptor.id === id); const tool = (kind: 'open' | 'close') => registry.handleChannelToolCall('channel_' + kind, { serverId: 'test', channelId: 'x' }); + // A machine's close, as subscription-gc makes it: no decision the agent's + // speech holds to, so speech opens the channel again (speechOpens), where + // the agent's own close (tool('close')) stands against it. + const machineClose = () => registry.handleChannelToolCall('channel_close', + { serverId: 'test', channelId: 'x', source: 'subscription-gc', overrideExplicitOpen: true }, + { kind: 'module', moduleName: 'subscription-gc' }); // An out-of-order server serves the most recently received operation first. // The fixed host should never have two in flight for this channel. const drain = async (...work: Promise[]) => { @@ -97,7 +103,7 @@ function fixture(onNotice?: NoticeHandler, store?: JsStore, coalesce?: RegistryO assert.equal(settled, true, 'lifecycle operations must settle'); await result; }; - return { registry, actual, calls, pending, held, traces, isOpen, tool, drain, publishedWhileOpen, notices, routeFailures, replaceServer, answeredFor, declares, setGrant }; + return { registry, actual, calls, pending, held, traces, isOpen, tool, machineClose, drain, publishedWhileOpen, notices, routeFailures, replaceServer, answeredFor, declares, setGrant }; } test('remove/re-add during reconcile converges on the replacement desired state, with ACK first (#185)', async () => { @@ -312,13 +318,13 @@ test('speech does not replace a pending explicit close with a new open decision' assert.equal(f.notices.length, 0); }); -test('a draft published into a channel with a close pending waits for it and posts nothing, as speech does', async () => { +test("a draft published into a channel with a machine's close pending waits for it and posts nothing, as speech does", async () => { // Drafts publish through the same open step as routed speech, so they meet // the channel's queued lifecycle work as speech and replies do. const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); f.held.add('x'); - const close = f.tool('close'); + const close = f.machineClose(); await tick(); const draft = f.registry.publish('resident', 'a draft', { serverId: 'test', channelId: 'x' }); await f.drain(close, draft); @@ -439,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, @@ -500,14 +531,14 @@ test('a grant without channels.publish after the words wait for a new registrati await announced; }); -test('a close decided while its opening is announced refuses the words, as a close decided while they wait does', async () => { +test("a machine's close decided while its opening is announced refuses the words, as a close decided while they wait does", async () => { // The registration is the one the waits returned and still open, but a // close is now decided and queued: the words wait for it and are refused. let closing: Promise | undefined; let f!: ReturnType; f = fixture(() => { f.held.add('x'); - closing = f.tool('close'); + closing = f.machineClose(); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); @@ -518,12 +549,33 @@ test('a close decided while its opening is announced refuses the words, as a clo assert.equal(await speech, null); assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); assert.equal(f.routeFailures[0]?.reason, 'a decision to close the channel came before these words went out'); + assert.equal(f.isOpen(), false, 'the close stands'); +}); + +test("the agent's own close decided while its opening is announced lets the words go into the channel as that close leaves it", async () => { + // The agent's close stands against its own speech, so the words don't + // wait for it to be refused: they go into the channel as it stands. + let closing: Promise | undefined; + let f!: ReturnType; + f = fixture(() => { + f.held.add('x'); + closing = f.tool('close'); + }); + await f.registry.handleChanged('test', { added: [descriptor('x')] }); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + await f.drain(speech); + assert.equal((await speech)?.delivered, true); + assert.equal(f.publishedWhileOpen.length, 1, 'the words were published'); + assert.deepEqual(f.routeFailures, []); + await f.drain(closing!); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); assert.equal(f.isOpen(), false, 'the close the agent decided stands'); }); -test('a retarget with nothing queued for it refuses the words, rather than publishing into it unconfirmed', async () => { - // Without channels.lifecycle the host queues no open for a retarget, so - // nothing will confirm the new target: the words are refused, not sent. +test("on a server whose lifecycle this host doesn't direct, a retarget with nothing queued for it takes the words as it stands", async () => { + // Without channels.lifecycle the host queues no open for a retarget, and + // opens nothing for speech: the server keeps its channels as it chooses, + // so the words go to the channel as it stands. let announced: Promise | undefined; let f!: ReturnType; f = fixture(() => { @@ -532,9 +584,8 @@ test('a retarget with nothing queued for it refuses the words, rather than publi }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); const outcome = await f.registry.publish('resident', 'words', { serverId: 'test', channelId: 'x' }); - assert.equal(outcome.status, 'failed'); - assert.equal(outcome.reason, "the channel isn't confirmed open"); - assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); + assert.equal(outcome.status, 'delivered'); + assert.equal(f.publishedWhileOpen.length, 1, 'the words were published'); assert.deepEqual(f.calls.filter((c) => c.kind === 'open').map((c) => c.address), [{ value: 'original' }], 'no open was queued for the new target'); await announced; }); @@ -650,6 +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"); }); +// 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("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'), 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 agent's open is in flight"); + const close = f.tool('close'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); + 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 ?? '', 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'); @@ -718,10 +872,10 @@ test('policy announces intent before transport rather than a stale open after su assert.equal(f.notices.length, 1, 'no stale confirmation is emitted after the later close'); }); -test('a fresh delivery into a settled closed channel remains a new engagement', async () => { +test('a fresh delivery into a channel a machine closed remains a new engagement', async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); - assert.equal((await f.tool('close')).success, true); + assert.equal((await f.machineClose()).success, true); assert.equal(f.actual.get('x'), false); const delivered = await f.registry.routeSpeech('resident', 'a new engagement', { serverId: 'test', channelId: 'x' }); assert.equal(delivered?.delivered, true); @@ -729,14 +883,14 @@ test('a fresh delivery into a settled closed channel remains a new engagement', assert.equal(f.notices.length, 1); }); -test('a close that completes while delivery joins a backscroll remains authoritative', async () => { +test("a machine's close that completes while delivery joins a backscroll remains authoritative", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); f.held.add('x'); const backscroll = f.registry.handleChannelToolCall('channel_open', { serverId: 'test', channelId: 'x', backscroll: 5 }); await tick(); const speech = f.registry.routeSpeech('resident', 'tail', { serverId: 'test', channelId: 'x' }); - const close = f.tool('close'); + const close = f.machineClose(); await f.drain(backscroll, speech, close); assert.equal((await close).success, true); assert.equal(await speech, null); @@ -837,12 +991,12 @@ test('an unconfirmed target retries explicit close instead of claiming already c }); for (const kind of ['speech', 'reply'] as const) { - test(kind + ' joins a pending close even after retargeting clears open confirmation', async () => { + test(kind + " joins a machine's pending close even after retargeting clears open confirmation", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true, 'A')] }); f.calls.length = 0; f.held.add('x'); - const closing = f.tool('close'); + const closing = f.machineClose(); await tick(); await f.registry.handleChanged('test', { updated: [descriptor('x', true, 'B')] }); assert.equal(f.isOpen(), false); @@ -1055,30 +1209,38 @@ test('an explicit open waits for a close in flight rather than answering already }); for (const delivery of ['reply', 'speech'] as const) { - test('a ' + delivery + ' into a channel whose close failed opens it again, and says so', async () => { - const f = fixture(); - await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); - f.calls.length = 0; - f.held.add('x'); - const closing = f.tool('close'); - await tick(); - f.pending.shift()!.fail(); - assert.equal((await closing).success, false); - await tick(); - assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); - assert.equal(f.isOpen(), true, 'a failed same-target close leaves the transport known open'); - f.held.delete('x'); - if (delivery === 'reply') { - const reply = await f.registry.openIfClosedForSend('x', 'test'); - assert.equal(reply.status, 'opened', 'the reply turned a channel decided closed open again'); - } else { - assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); - assert.equal(f.notices.length, 1, 'the speech turned a channel decided closed open again'); - assert.deepEqual(f.publishedWhileOpen, [true]); + test('a ' + delivery + " into a channel whose machine close failed opens it again, and says so; one whose own close failed goes out with that close standing", async () => { + for (const closer of ['machine', 'agent'] as const) { + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); + f.calls.length = 0; + f.held.add('x'); + const closing = closer === 'agent' ? f.tool('close') : f.registry.handleChannelToolCall('channel_close', + { serverId: 'test', channelId: 'x', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + await tick(); + f.pending.shift()!.fail(); + assert.equal((await closing).success, false); + await tick(); + assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); + assert.equal(f.isOpen(), true, 'a failed same-target close leaves the transport known open'); + f.held.delete('x'); + // A machine's close is no decision speech holds to, so the words open + // the channel again; the agent's own close stands against its words, + // which go out into the transport the failed close left open. + const reopens = closer === 'machine'; + if (delivery === 'reply') { + const reply = await f.registry.openIfClosedForSend('x', 'test'); + assert.equal(reply.status, reopens ? 'opened' : 'stays-closed', `${closer}: what the reply did`); + } else { + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); + assert.equal(f.notices.length, reopens ? 1 : 0, `${closer}: whether the speech turned a channel decided closed open again`); + assert.deepEqual(f.publishedWhileOpen, [true]); + } + assert.equal(f.registry.getDesiredState('test', 'x'), reopens ? 'open' : 'closed', closer); + assert.equal(f.traces.filter((e) => e.type === 'mcpl:channel-opened-by-send').length, reopens ? 1 : 0, closer); + assert.deepEqual(f.calls.map((c) => c.kind), reopens ? ['close', 'open'] : ['close'], + reopens ? 'the transport is confirmed again after the failed close' : 'nothing reopens it'); } - assert.equal(f.registry.getDesiredState('test', 'x'), 'open'); - assert.equal(f.traces.filter((e) => e.type === 'mcpl:channel-opened-by-send').length, 1); - assert.deepEqual(f.calls.map((c) => c.kind), ['close', 'open'], 'the transport is confirmed again after the failed close'); }); } @@ -1605,14 +1767,14 @@ test('the notices say what happened, name the opt-out, and state no rule about l assert.deepEqual(texts, [ "[channels] Now open: #general (discord:g1:c1): your own message into the closed channel opened it. " + "What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that. If you don't want it, " + - "use channel_close: neither the subscription policy nor its server's own default opens it again.", + "use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again.", "[channels] Now open: c2: your own message into the closed channel opened it. " + "What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that. If you don't want it, " + - "use channel_close: neither the subscription policy nor its server's own default opens it again.", + "use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again.", '[channels] Selected for opening by subscription policy: c3, ops (c4). ' + "The host opens them, and if one doesn't open, tries again when its server next registers it; channel_list shows whether each is open yet. " + "What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that. If you don't want one of them, " + - "use channel_close: neither the subscription policy nor its server's own default opens it again.", + "use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again.", ]); } finally { await framework.stop(); @@ -1808,34 +1970,50 @@ test('after speech whose open failed, the server announcing the channel again op assert.deepEqual(f.notices, []); }); -test("the agent's close made while speech's open is out stands when that open fails", async () => { +test("a close made while speech's open is out stands when that open fails: a machine's fails the words, and the agent's own lets them go into the channel it closed", async () => { + { + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); + const { outcome } = await speechWhoseOpenFails(f, () => [f.machineClose()]); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.reason, "opening the channel for these words didn't complete: fixture lifecycle failure; a decision about the " + + 'channel made while the open was out stands', "the open's own failure, carried as overtaken, and the decision that stands"); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); + } const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); const { outcome } = await speechWhoseOpenFails(f, () => [f.tool('close')]); - assert.equal(outcome.status, 'failed'); - assert.equal(outcome.reason, "opening the channel for these words didn't complete: fixture lifecycle failure; a decision about the " + - 'channel made while the open was out stands', "the open's own failure, carried as overtaken, and the decision that stands"); + assert.equal(outcome.status, 'delivered', 'the agent closed it meanwhile, so the words go into it as it stands'); assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + assert.equal(f.notices.length, 0, 'nothing says it opened'); }); -test("speech whose open was answered after the agent closed the channel meanwhile names that close once", async () => { +test("speech whose open was answered after a machine closed the channel meanwhile names that close once; after the agent's own close, the words go into the channel it closed", async () => { // The open's own failure is the later close, so its clause names it, and // the overtaken failure adds nothing to it. - const f = fixture(); - await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); - f.held.add('x'); - const speech = f.registry.publish('agent', 'words', { serverId: 'test', channelId: 'x' }); - await tick(); - assert.equal(f.pending.length, 1, "speech's open is out"); - const closing = f.tool('close'); - await tick(); - f.held.delete('x'); - f.pending.shift()!.finish(); - await f.drain(speech, closing); - const outcome = await speech; - assert.equal(outcome.status, 'failed'); - assert.equal(outcome.reason, "opening the channel for these words didn't complete: a later decision to close it came first"); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + for (const closer of ['machine', 'agent'] as const) { + const f = fixture(); + await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); + f.held.add('x'); + const speech = f.registry.publish('agent', 'words', { serverId: 'test', channelId: 'x' }); + await tick(); + assert.equal(f.pending.length, 1, "speech's open is out"); + const closing = closer === 'agent' ? f.tool('close') : f.machineClose(); + await tick(); + f.held.delete('x'); + f.pending.shift()!.finish(); + await f.drain(speech, closing); + const outcome = await speech; + if (closer === 'machine') { + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.reason, "opening the channel for these words didn't complete: a later decision to close it came first"); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); + } else { + assert.equal(outcome.status, 'delivered'); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + assert.equal(f.notices.length, 0); + } + } }); test("the agent's channel_open made while speech's open is out stands when that open fails", async () => { @@ -1851,11 +2029,11 @@ test("the agent's channel_open made while speech's open is out stands when that assert.equal(f.isOpen(), true); }); -test("a reply whose open fails puts back the agent's close", async () => { +test("a reply whose open fails puts back the machine's close it replaced; a reply into a channel the agent closed sends no open", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); - await f.drain(f.tool('close')); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + await f.drain(f.machineClose()); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); f.held.add('x'); const reply = f.registry.openIfClosedForSend('x', 'test'); @@ -1863,7 +2041,13 @@ test("a reply whose open fails puts back the agent's close", async () => { f.pending.shift()!.fail(); f.held.delete('x'); assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: true }, 'an open that would have opened the channel'); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }, "the agent's close is on record again"); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }, "the machine's close is on record again"); + + await f.drain(f.tool('close')); + const calls = f.calls.length; + assert.deepEqual(await f.registry.openIfClosedForSend('x', 'test'), { status: 'stays-closed', channelId: 'x', serverId: 'test', label: 'x' }); + assert.equal(f.calls.length, calls, 'no open was sent'); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); }); test("a reply's open that the agent's own close replaced during its round-trip isn't an opening that failed", async () => { @@ -1882,7 +2066,8 @@ test("a reply's open that the agent's own close replaced during its round-trip i f.held.delete('x'); await f.drain(reply, closed); assert.equal(((await closed) as { success: boolean }).success, true); - assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }, 'nothing to tell an agent that just closed it'); + assert.deepEqual(await reply, { status: 'stays-closed', channelId: 'x', serverId: 'test', label: 'x' }, + 'the channel the agent just closed stays closed, and nothing failed to tell it of'); assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }, "the agent's close stands"); assert.equal(f.isOpen(), false); }); @@ -1937,7 +2122,7 @@ test("the put-back is durable: a registry rebuilt from the store doesn't hold th } }); -test('when speech\'s open fails while the server announces the channel again, the queued reconcile carries out the put-back: one close, no notice', async () => { +test("when speech\'s open fails while the server announces the channel again, the queued reconcile carries out the put-back: one close, no notice", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); f.held.add('x'); @@ -2076,41 +2261,41 @@ test("a failed open's put-back whose lift can't be recorded leaves the default, assert.deepEqual(f.calls.map((c) => c.kind), ['close', 'open', 'close'], 'the queued work carries out the close'); }); -test("a failed open by speech that put back a decision the agent made isn't lifted by the server's announcement: only a pure default follows it", async () => { +test("a failed open by speech that put back a machine's close isn't lifted by the server's announcement: only a pure default follows it", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); await tick(); await tick(); - // The agent's close fails at the server, so x is decided closed with its + // A machine's close fails at the server, so x is decided closed with its // transport still open, and speech engages it. f.held.add('x'); - const close = f.tool('close'); + const close = f.machineClose(); await tick(); f.pending.shift()!.fail(); f.held.delete('x'); await f.drain(close); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); const { outcome } = await speechWhoseOpenFails(f, () => [f.registry.handleChanged('test', { added: [descriptor('x', true)] })]); assert.equal(outcome.status, 'failed'); assert.equal(outcome.reason, "opening the channel for these words didn't complete: fixture lifecycle failure", 'nothing selects it for opening again'); await tick(); await tick(); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }, "the agent's close stands"); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }, "the machine's close stands"); }); -test('speech that waits on a failed open into a channel the agent closed is refused, as the close is on record again', async () => { +test('speech that waits on a failed open into a channel a machine closed is refused, as the close is on record again', async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); await tick(); await tick(); assert.equal(f.actual.get('x'), true); - // The agent's close fails at the server, so x is decided closed with its + // A machine's close fails at the server, so x is decided closed with its // transport still open. f.held.add('x'); - const close = f.tool('close'); + const close = f.machineClose(); await tick(); f.pending.shift()!.fail(); f.held.delete('x'); await f.drain(close); assert.equal((await close).success, false); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); f.held.add('x'); const first = f.registry.publish('agent', 'first', { serverId: 'test', channelId: 'x' }); @@ -2123,8 +2308,8 @@ test('speech that waits on a failed open into a channel the agent closed is refu await f.drain(first, second); assert.equal((await first).status, 'failed'); assert.equal((await second).status, 'failed', 'the second speech waited on that open and is refused'); - assert.deepEqual(f.publishedWhileOpen, [], 'nothing is published into the channel the agent closed'); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); + assert.deepEqual(f.publishedWhileOpen, [], 'nothing is published into the channel the machine closed'); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'subscription-gc' }); }); test("an open whose answer is lost after the server opened the channel is closed on the wire too, since its transport wasn't confirmed", async () => { @@ -2157,14 +2342,14 @@ test("a reply on a server without channels.lifecycle writes no decision, so the await f.registry.handleIncoming('test', { messages: [message()] }); const log = recordLog(f.registry); for (let i = 0; i < 3; i++) { - assert.equal((await f.registry.openIfClosedForSend('x', 'test')).status, 'open-failed'); + assert.equal((await f.registry.openIfClosedForSend('x', 'test')).status, 'already-open', 'its traffic showed it open, and nothing is tried'); } await tick(); assert.deepEqual(log, [], 'no record is written'); assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); }); -test("speech into a channel that isn't open, on a server without channels.lifecycle, writes no decision", async () => { +test("speech into a channel that isn't open, on a server without channels.lifecycle, goes out and writes no decision", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); await tick(); await tick(); @@ -2172,8 +2357,8 @@ test("speech into a channel that isn't open, on a server without channels.lifecy const log = recordLog(f.registry); const outcome = await f.registry.publish('agent', 'words', { serverId: 'test', channelId: 'x' }); await tick(); - assert.equal(outcome.status, 'failed'); - assert.match(outcome.reason ?? '', /^opening the channel for these words didn't complete: this host isn't permitted to open or close the "test" server's channels/); + assert.equal(outcome.status, 'delivered', 'nothing opens there, and the words go into the channel as it stands'); + assert.equal(f.publishedWhileOpen.length, 1); assert.deepEqual(log, [], 'no record is written'); assert.deepEqual(decision(f.registry), { state: 'closed', source: 'default-closed' }); }); @@ -2343,6 +2528,8 @@ async function replyOpenFailedNotices(opts: { ...(opts.selectedForOpening ? { selectedForOpening: opts.selectedForOpening } : {}) }; }, directsLifecycle: () => opts.directs && !(opts.dropsDuring && opened), + // The send's own channel, which nobody closed, so its answer carries no note. + resolveSendTarget: () => undefined, // The notice names the channel as the agent is shown it: here its id. nameShown: (channel: { channelId: string }) => channel.channelId, }; @@ -2592,8 +2779,8 @@ test('the opened notice names channel_close only where the agent it\'s addressed // traffic. Where conversations are routed (router), that's a fork for its // home alone; elsewhere, the agent addMessage delivers to. const optOut = " What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that."; - const close = " If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again."; - const closeMany = " If you don't want one of them, use channel_close: neither the subscription policy nor its server's own default opens it again."; + const close = " 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, the subscription policy nor its server's own default opens it again."; + const closeMany = " If you don't want one of them, use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again."; const own = '[channels] Now open: #x (x): your own message into the closed channel opened it.'; const opened = (passOn: unknown) => ({ system: true, kind: 'channel-notice', passOn }); const fromReply = { notice: 'channel-opened', serverId: 'srv', cause: 'opened-by-reply', channels: [{ channelId: 'x', label: '#x' }] }; @@ -2872,7 +3059,7 @@ test("a reply that waits on a queued close, which leaves the channel closed, isn f.held.delete('x'); const before = f.calls.length; await f.drain(closed, reply); - assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }); + assert.deepEqual(await reply, { status: 'stays-closed', channelId: 'x', serverId: 'test', label: 'x' }); assert.deepEqual(f.calls.slice(before).filter((c) => c.kind === 'open'), [], 'the reply sent no open'); assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); }); @@ -2895,7 +3082,7 @@ test("a reply into a channel whose server is gone writes no decision and queues assert.deepEqual(decision(f.registry), { state: 'closed', source: 'default-closed' }); }); -test("the agent's channel_close over its own decline writes nothing: both are the agent's", async () => { +test("the agent's channel_close over its own decline is recorded: the latest deliberate close is the one on record", async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); await tick(); await tick(); @@ -2904,8 +3091,8 @@ test("the agent's channel_close over its own decline writes nothing: both are th const log = recordLog(f.registry); const closed = await f.tool('close'); assert.equal(dataOf(closed).status, 'already closed'); - assert.deepEqual(log, []); - assert.deepEqual(decision(f.registry), { state: 'closed', source: 'invitation-declined' }); + assert.deepEqual(log, [{ desired: 'closed', source: 'agent-tool' }], 'the latest deliberate close is the one on record'); + assert.deepEqual(decision(f.registry), { state: 'closed', source: 'agent-tool' }); }); test("channel_open on a server without channels.lifecycle still records the agent's decision, though its open fails", async () => { @@ -3131,7 +3318,7 @@ test("a request in flight when the transport closes fails without undoing the cl assert.deepEqual(f.calls.map((c) => c.kind), ['close']); }); -test("where the host doesn't direct the lifecycle, a close withdraws what traffic confirmed: delivery is refused until the next transport's traffic confirms it", async () => { +test("where the host doesn't direct the lifecycle, a close withdraws what traffic confirmed, and delivery goes to the channel as it stands until the next transport's traffic confirms it open", async () => { const f = fixture(); f.setGrant(ALL_CAPABILITY_PATHS.filter((path) => path !== 'channels.lifecycle')); await f.registry.handleChanged('test', { added: [descriptor()] }); @@ -3141,10 +3328,9 @@ test("where the host doesn't direct the lifecycle, a close withdraws what traffi assert.equal(f.isOpen(), false); f.calls.length = 0; const outcome = await f.registry.publish('resident', 'words', { serverId: 'test', channelId: 'x' }); - assert.equal(outcome.status, 'failed'); - assert.match(String(outcome.reason), /isn't permitted to open or close the "test" server's channels/, 'refused because the host may not open it'); + assert.equal(outcome.status, 'delivered', 'the words go into the channel as it stands'); assert.deepEqual(f.calls, [], 'nothing is sent to open it'); - assert.deepEqual(f.publishedWhileOpen, []); + assert.equal(f.publishedWhileOpen.length, 1); await f.registry.handleIncoming('test', { messages: [message('x', 'm2')] }); assert.equal(f.isOpen(), true, "the next transport's traffic shows it open again"); }); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index eaa4b4a41..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 () => { @@ -443,6 +446,100 @@ test('routeSpeech does NOT deliver when the open-on-delivery fails', async () => assert.equal(failures[0]!.outcome, 'failed', 'nothing was published'); }); +test('routeSpeech publishes into a closed channel on a server whose lifecycle this host doesn\'t direct, where nothing opens', async () => { + const published: Array = []; + const opens: 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 }; + }, + sendChannelsOpen: async (params: { channelId?: string }) => { + opens.push(params.channelId); + return {}; + }, + }; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + ); + seedRegistered(registry, 'portal', 'lobby'); + const lobby = { serverId: 'portal', channelId: 'lobby' }; + + const res = await registry.routeSpeech('sol', 'hello from outside', lobby); + + assert.deepEqual(res, { delivered: true, serverId: 'portal', channelId: 'lobby' }, 'nothing opens, and the words still go'); + assert.deepEqual(published, ['lobby']); + assert.deepEqual(opens, [], 'no open is tried'); + assert.equal(registry.directsLifecycle('portal'), false); + assert.equal(registry.speechOpens(lobby), false); + assert.equal(registry.getDesiredState('portal', 'lobby'), undefined, 'speech records no intent to open'); +}); + +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' }, + ], + } as never); + const origin = { kind: 'agent' as const, agentName: 'sol' }; + 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; + + for (const id of ['closed-by-agent', 'declined-by-agent']) { + const res = await registry.routeSpeech('sol', 'answering', discord(id)); + assert.deepEqual(res, { delivered: true, serverId: 'discord', 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'); + } + 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("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' }, + { id: 'hk-a', type: 'discord', label: '#h', direction: 'bidirectional' }, + ], + } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + // 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, 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); + assert.deepEqual(closed.data, { channelId: 'gc-b', serverId: 'discord', status: 'already closed' }); + assert.equal(registry.closedByAgent(discord('gc-b')), 'closed'); + const opensBefore = openCalls.length; + + for (const id of ['gc-a', 'gc-b', 'hk-a']) { + assert.ok(await registry.routeSpeech('sol', 'answering', discord(id)), `${id}: delivered`); + } + + 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 () => { const { registry, failures, traces } = makeRegistry({ delivered: true }); seedRegistered(registry, 'discord', 'ch-y'); @@ -530,6 +627,355 @@ test('openIfClosedForSend resolves raw server-internal ids and opens the channel assert.ok(openCalls.some(c => c.channelId === 'discord:dm:444555')); }); +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'); + assert.equal(registry.getDesiredState('discord', 'lazy-1'), undefined); + const gc = await registry.handleChannelToolCall( + 'channel_close', { channelId: 'lazy-1', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + assert.equal(gc.success, true, gc.error); + 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("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, + /You can still speak there\. 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`); + 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' }); + 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'); + // 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, and which applies opens and closes in + * the order they arrive. */ +function loggedRegistry(events: unknown[]) { + const openCalls: Array<{ channelId?: string }> = []; + const closeCalls: Array<{ channelId?: string }> = []; + const published: Array = []; + const serverOpen = new Set(); + 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); + if (params.channelId) serverOpen.add(params.channelId); + openReached(); + if (openHold) await openHold; + return {}; + }, + sendChannelsClose: async (params: { channelId?: string }) => { + closeCalls.push(params); + if (params.channelId) serverOpen.delete(params.channelId); + return {}; + }, + sendChannelsPublish: async (params: { channelId?: string }) => { + published.push(params.channelId); + return { delivered: true }; + }, + sendChannelsAcknowledge: async () => ({ acknowledged: 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 failures: Array<{ reason: string }> = []; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, + { + store: store as never, + onChannelAutoOpened: (info) => { autoOpened.push(info); }, + onRouteFailure: (info) => { failures.push(info); }, + }, + ); + return { + registry, openCalls, closeCalls, published, serverOpen, autoOpened, failures, + /** 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', discord(id)), { delivered: true, serverId: 'discord', 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("words waiting on an open speech started go into the channel as it stands once the agent closes or declines it meanwhile, and nothing says it opened", async () => { + const { registry, holdOpens, autoOpened, serverOpen, published, failures } = loggedRegistry([]); + await registry.handleRegister('discord', { channels: channelsOf('closed-meanwhile', 'declined-meanwhile') } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + + for (const [id, decide] of [ + ['closed-meanwhile', () => registry.handleChannelToolCall('channel_close', { channelId: 'closed-meanwhile' }, agent)], + ['declined-meanwhile', () => registry.handleChannelToolCall('channel_decline', { channelId: 'declined-meanwhile', messageId: 'm1' }, agent)], + ] as const) { + // Speech opens it, since nobody decided about it; the agent decides while the open is on its way. + const held = holdOpens(); + const speaking = registry.routeSpeech('sol', 'answering', discord(id)); + await held.reached; + assert.equal(registry.getDesiredState('discord', id), 'open', "the open's intent is recorded"); + const decided = decide(); + held.release(); + assert.equal((await decided).success, true); + assert.deepEqual(await speaking, { delivered: true, serverId: 'discord', channelId: id }, `${id}: the words went`); + assert.equal(published.at(-1), id); + assert.equal(registry.getDesiredState('discord', id), 'closed', `${id}: the decision made meanwhile stands`); + assert.equal(registry.isChannelOpen(discord(id)), false); + assert.equal(serverOpen.has(id), false, `${id}: the server applied the open, then the close`); + } + assert.deepEqual(autoOpened, [], 'nothing reports either channel as opened'); + assert.deepEqual(failures, [], 'and no words were refused'); +}); + +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') } 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 + // agent's close waits behind it, and so do the words. + const held = holdOpens(); + const reopening = registry.handleChannelToolCall('channel_open', { channelId: 'closing', backscroll: 5, beforeMessageId: 'm0' }, agent); + await held.reached; + const closing = registry.handleChannelToolCall('channel_close', { channelId: 'closing' }, agent); + const speaking = registry.routeSpeech('sol', 'answering', discord('closing')); + const opensBefore = openCalls.length; + held.release(); + await reopening; + assert.equal((await closing).success, true); + assert.deepEqual(await speaking, { delivered: true, serverId: 'discord', channelId: 'closing' }); + assert.equal(published.at(-1), 'closing'); + assert.equal(openCalls.length, opensBefore, 'the words opened nothing'); + assert.equal(registry.isChannelOpen(discord('closing')), false); + assert.ok(closeCalls.some((c) => c.channelId === 'closing')); +}); + +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, publishCalls, 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(discord('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', discord('down')), { delivered: true, serverId: 'discord', channelId: 'down' }); + assert.equal(publishCalls.at(-1)?.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.equal(refused.error, 'Channel "down" is open, so there\'s nothing to decline. If you need it closed, call channel_close.'); +}); + +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; + const on = (serverId: string, channelId: string): ChannelRef => ({ serverId, channelId }); + + assert.deepEqual(registry.resolveSendTarget('discord:g:444'), on('discord-a', 'discord:g:444'), 'a descriptor id'); + assert.deepEqual(registry.resolveSendTarget('444', 'discord-a'), on('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.deepEqual(registry.resolveSendTarget('555', 'discord-b'), on('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("a send opens a closed channel speech opens, and leaves every other one as it stands, answering stays-closed", async () => { + const { registry, openCalls, traces, lookup, setOnClose } = makeRegistry({ delivered: true }); + const ids = ['undecided', 'agent-closed', 'declined', 'module-closed', 'gc-closed', 'decided-open', 'open-now', 'close-failed']; + await registry.handleChanged('discord', { added: channelsOf(...ids) } as never); + const agent = { kind: 'agent' as const, agentName: 'sol' }; + await registry.handleChannelToolCall('channel_close', { channelId: 'agent-closed' }, agent); + await registry.handleChannelToolCall('channel_decline', { channelId: 'declined', messageId: 'm1' }, agent); + await registry.handleChannelToolCall('channel_close', { channelId: 'module-closed' }, { kind: 'module' }); + await registry.handleChannelToolCall('channel_close', { channelId: 'gc-closed', source: 'subscription-gc' }, { kind: 'module', moduleName: 'subscription-gc' }); + await registry.handleChannelToolCall('channel_open', { channelId: 'open-now' }, agent); + await registry.handleChannelToolCall('channel_open', { channelId: 'decided-open' }, agent); + await registry.handleChannelToolCall('channel_open', { channelId: 'close-failed' }, agent); + // The decided-open channel's transport is down; the agent's close of + // close-failed is on record, though the server refused it. + lookup('decided-open')!.open = false; + setOnClose(async () => { throw new Error('close refused by server'); }); + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: 'close-failed' }, agent)).success, false); + setOnClose(undefined); + assert.equal(registry.getDesiredState('discord', 'close-failed'), 'closed'); + assert.equal(lookup('close-failed')!.open, true, 'its transport is still open'); + const opensBefore = openCalls.length; + + assert.deepEqual(ids.map((id) => [id, registry.speechOpens(discord(id))]), [ + ['undecided', true], ['agent-closed', false], ['declined', false], ['module-closed', false], ['gc-closed', true], + ['decided-open', true], ['open-now', false], ['close-failed', false], + ]); + assert.equal(registry.speechOpens(discord('nowhere')), false); + + const statuses: Array<[string, string]> = []; + for (const id of ids) statuses.push([id, (await registry.openIfClosedForSend(id, 'discord')).status]); + assert.deepEqual(statuses, [ + ['undecided', 'opened'], ['agent-closed', 'stays-closed'], ['declined', 'stays-closed'], ['module-closed', 'stays-closed'], + ['gc-closed', 'opened'], ['decided-open', 'already-open'], ['open-now', 'already-open'], ['close-failed', 'stays-closed'], + ], 'a reconfirmed transport of a channel decided open opens no closed channel'); + assert.deepEqual((await registry.openIfClosedForSend('nowhere', 'discord')).status, 'unknown-channel'); + // A channel open at the transport with no decision on record, as a push's + // registration that traffic confirmed: the send records its engagement as + // an open, as it always did, which a retarget or a restart carries out. + seedRegistered(registry, 'discord', 'no-record'); + lookup('no-record')!.open = true; + assert.equal(registry.getDesiredState('discord', 'no-record'), undefined); + assert.equal((await registry.openIfClosedForSend('no-record', 'discord')).status, 'opened'); + assert.equal(registry.getDesiredState('discord', 'no-record'), 'open'); + + assert.deepEqual(openCalls.slice(opensBefore).map((c) => c.channelId), ['undecided', 'gc-closed', 'decided-open', 'no-record']); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:channel-opened-by-send').map((t) => t.channelId), + ['undecided', 'gc-closed', 'no-record'], 'each open by a send is traced'); + assert.equal(registry.getDesiredState('discord', 'close-failed'), 'closed', "the agent's close stands"); + assert.equal(registry.closedByAgent(discord('agent-closed')), 'closed'); + assert.equal(registry.closedByAgent(discord('declined')), 'declined'); +}); + +test("a send answers already-open for an open channel on a server whose lifecycle this host doesn't direct, and stays-closed for a closed one, opening neither", async () => { + const opens: Array = []; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.lifecycle')), []), + sendChannelsOpen: async (params: { channelId?: string }) => { opens.push(params.channelId); return {}; }, + }; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, + ); + seedRegistered(registry, 'portal', 'lobby', 'hall'); + (registry as unknown as { channels: Map }).channels.get(destinationKey({ serverId: 'portal', channelId: 'hall' }))!.open = true; + assert.equal((await registry.openIfClosedForSend('lobby', 'portal')).status, 'stays-closed'); + assert.equal((await registry.openIfClosedForSend('hall', 'portal')).status, 'already-open'); + assert.deepEqual(opens, []); +}); + 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 { registry } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 1c3aaa1e4..4f030e6c9 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -2103,6 +2103,238 @@ describe('explicit prose routing', () => { await framework.stop(); }); + /** 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, + * all on discord. */ + 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' }, + '#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' }; + type Ref = { serverId: string; channelId: string }; + const deliverSpeech = async (_agent: string, text: string, locus?: Ref | null) => { + routed.push({ text, locus: locus?.channelId ?? null }); + const wait = typeof held === 'function' ? held(locus?.channelId ?? null) : held; + if (wait) await wait; + return { status: 'delivered' as const, destination: { serverId: 'discord', channelId: locus?.channelId ?? '' }, at: Date.now() }; + }; + const registry: Record = { + resolveProseTarget: (spec: string) => (known[spec] ? { serverId: 'discord', ...known[spec] } : { error: `no channel matches "${spec}"` }), + deliverSpeech, + routeSpeech: async (agent: string, text: string, locus?: Ref | null) => { + const outcome = await deliverSpeech(agent, text, locus); + return { delivered: true, serverId: outcome.destination.serverId, channelId: outcome.destination.channelId }; + }, + resolveLocus: () => ({ serverId: 'discord', channelId: 'chan-alpha' }), + getDefaultPublishChannel: () => null, + isChannelOpen: (c: Ref) => c.channelId === 'chan-alpha', + closedByAgent: (c: Ref) => (c.channelId === 'discord:g:cafe' ? 'closed' : undefined), + directsLifecycle: () => true, + nameShown: (c: Ref) => c.channelId, + getDescriptor: (c: Ref) => (labels[c.channelId] ? { label: labels[c.channelId] } : 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: 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.'; + + function windowTexts(framework: AgentFramework): string[] { + return framework.getAgent('assistant')!.getContextManager().getAllMessages() + .flatMap((m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : []))); + } + + 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[])); + 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('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' }, + { 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 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' } }, + ] 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[]); + + // 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("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[])); + const framework = await createFramework('hybrid'); + const routed = closedNoteRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed.map((r) => r.locus), ['discord:g:cafe']); + 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(); + }); + 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/inbound-source.test.ts b/test/inbound-source.test.ts index 2c84f2dc0..73db7dc82 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -114,6 +114,14 @@ describe('inbound source envelope', () => { const stored = () => framework.getAgent('scout')!.getContextManager().getAllMessages(); const storedWith = (pred: (m: Record) => boolean) => stored().find((m) => pred((m.metadata ?? {}) as Record)); + /** The agent closes ROOM, so an addressed push from it comes with the closed-channel invitation. */ + const closeRoom = async (): Promise => { + const registry = (framework as unknown as { + channelRegistry: { handleChannelToolCall(name: string, input: unknown, caller: unknown): Promise<{ success: boolean }>; isChannelOpen(channel: ChannelRef): boolean }; + }).channelRegistry; + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId: ROOM }, { kind: 'agent', agentName: 'scout' })).success, true); + assert.equal(registry.isChannelOpen({ serverId: 'discord', channelId: ROOM }), false); + }; it('stamps a channels/incoming message with its registered channel, label, reply edge and acceptance time', async () => { const before = Date.now(); @@ -227,15 +235,28 @@ describe('inbound source envelope', () => { }); it('on the push lane, the delivered digest excludes host decoration and the stored digest covers it', async () => { - command({ op: 'dm', eventId: 'ev-dig', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); - await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dig'), 'dm stored'); + // A mention pushed from a channel the agent closed: the host appends its invitation. + await closeRoom(); + command({ + op: 'push', eventId: 'ev-dig', text: 'psst', + origin: { source: 'discord', mcplChannelId: ROOM, messageId: 'pm-dig', authorName: 'antra' }, + tags: ['chat:mention', 'chat:addressed'], + }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dig'), 'push stored'); const message = storedWith((m) => m.eventId === 'ev-dig')!; const meta = message.metadata as Record; assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'psst' }]), 'the adapter body alone'); assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored'); - // The DM's channel is closed, so the host appends its invitation. assert.equal(message.content.length, 2); assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the closed-channel invitation decorates the stored copy'); + // A DM's channel is closed too, but all of a DM reaches the agent, so + // nothing decorates it and its two digests agree. + command({ op: 'dm', eventId: 'ev-dm', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dm'), 'dm stored'); + const dm = storedWith((m) => m.eventId === 'ev-dm')!; + const dmMeta = dm.metadata as Record; + assert.equal(dmMeta.channelInvitation, undefined, 'no invitation on a DM'); + assert.equal(dmMeta.storedBodyDigest, dmMeta.sourceBodyDigest); }); it('a visibly-empty ordinary push is refused and stores nothing; an accepted push carries the host\'s digests, never the adapter\'s', async () => { @@ -255,12 +276,17 @@ describe('inbound source envelope', () => { assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); }); - it('a DM pushed into a closed channel carries the host\'s digests, never the adapter\'s: the delivered body, and the stored copy with its invitation', async () => { + it('a mention pushed from a closed channel carries the host\'s digests, never the adapter\'s: the delivered body, and the stored copy with its invitation', async () => { + await closeRoom(); command({ - op: 'dm', eventId: 'ev-forged', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst again', - origin: { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + op: 'push', eventId: 'ev-forged', text: 'psst again', + origin: { + source: 'discord', mcplChannelId: ROOM, messageId: 'pm-forged', authorName: 'antra', + sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value', + }, + tags: ['chat:mention', 'chat:addressed'], }); - await waitFor(() => !!storedWith((m) => m.eventId === 'ev-forged'), 'dm stored'); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-forged'), 'push stored'); const message = storedWith((m) => m.eventId === 'ev-forged')!; const meta = message.metadata as Record; assert.equal(meta.channelInvitation, true, 'stored with the closed-channel invitation'); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 279af658f..eb256bf5f 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1903,6 +1903,8 @@ describe('present while acting', () => { framework.checkpointManager = null; framework.channelRegistry = { openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }), + // The send's own channel, which nobody closed, so its answer carries no note. + resolveSendTarget: () => undefined, directsLifecycle: () => true, }; framework.turnRouting = new Map([['assistant', sending]]); @@ -1964,6 +1966,8 @@ describe('present while acting', () => { framework.checkpointManager = null; framework.channelRegistry = { openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }), + // The send's own channel, which nobody closed, so its answer carries no note. + resolveSendTarget: () => undefined, directsLifecycle: () => true, }; const engaged = new Set(); @@ -2139,6 +2143,8 @@ describe('present while acting', () => { framework.checkpointManager = null; framework.channelRegistry = { openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }), + // The send's own channel, which nobody closed, so its answer carries no note. + resolveSendTarget: () => undefined, directsLifecycle: () => true, }; framework.turnRouting = new Map([['assistant', sending]]); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index e1d918054..622ba5246 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -1219,7 +1219,7 @@ describe('notices about an agent\'s own action', () => { assert.equal(primary.mock.callCount(), 1, 'the primary is told too'); assert.equal(JSON.stringify(primary.mock.calls[0]!.arguments[1]), JSON.stringify([{ type: 'text', text: `[channels] Now open: #one (c-1): a message from your fork in missing-channel, named ${fork.name}, into the closed channel opened it, and that fork can't close it. ` + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again." }])); + "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, the subscription policy nor its server's own default opens it again." }])); assert.equal((primary.mock.calls[0]!.arguments[2] as { speaker?: unknown } | undefined)?.speaker, fork.name); // The advice left out is advice the fork can't take: its channel_close @@ -1254,7 +1254,7 @@ describe('notices about an agent\'s own action', () => { assert.equal(told.length, 1, 'the primary is told once'); assert.equal((told[0]!.content[0] as { text: string }).text, `[channels] Now open: #home (missing-channel): a message from your fork in missing-channel, named ${fork.name}, into the closed channel opened it; that fork has ended. ` + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again."); + "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, the subscription policy nor its server's own default opens it again."); assert.equal((told[0]!.metadata as { speaker?: unknown } | undefined)?.speaker, fork.name); }); @@ -1412,7 +1412,7 @@ describe('notices about an agent\'s own action', () => { .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ "[channels] Now open: #four (c-4): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. " + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again.", + "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, the subscription policy nor its server's own default opens it again.", "[channels] Not open: #five (c-5). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ]); @@ -1484,11 +1484,11 @@ describe('notices about an agent\'s own action', () => { .filter((m) => JSON.stringify(m.content).includes('conversation-gone')); assert.deepEqual(told.map((m) => (m.content[0] as { text: string }).text), [ '[channels] Now open: #seven (c-7): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again.", + "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, the subscription policy nor its server's own default opens it again.", '[channels] Now open: #thirteen (c-13): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again.", + "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, the subscription policy nor its server's own default opens it again.", '[channels] Now open: #fourteen (c-14): a message from conversation-gone into the closed channel opened it; conversation-gone has ended. ' + - "If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again.", + "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, the subscription policy nor its server's own default opens it again.", "[channels] Not open: #twenty-two (srv:c-22). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + "It's selected for opening again, because its server lists it as open by default: the host tries again, and channel_close withdraws that.", ]); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts new file mode 100644 index 000000000..ad1e8d309 --- /dev/null +++ b/test/send-into-closed-channel.test.ts @@ -0,0 +1,955 @@ +/** + * 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, 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. 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'; +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, destinationKey } 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'; + +const channelId = 'discord:g1:444555'; +const origin = { kind: 'agent' as const, agentName: 'sol' }; +/** A channel on a server, discord unless named. */ +const ref = (id: string, serverId = 'discord') => ({ serverId, channelId: id }); +/** A turn's routing as its `[delivered]` receipt reads it: plain speech + * delivered to these channels on discord, and nothing else. */ +const receiptRouting = (...ids: string[]) => ({ deliveries: ids.map((id) => ref(id)), suppressed: 0, drafts: [], keptPrivate: 0 }); +/** The agent the harness's framework knows, with what a send's dispatch reads of it. */ +const agentStub = (more: Record = {}) => ({ + canUseTool: () => true, + getConsumedWatermark: () => null, + notePublished: () => {}, + ...more, +}); + +/** 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.'; + +/** A real ChannelRegistry with one registered channel, and a framework with + * 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; 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. + 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', + isConnected: true, + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + async sendToolsList() { + return { tools: ['send_message', 'reply_message', 'send_dm'].map((name) => ({ name, inputSchema: { type: 'object' } })) }; + }, + async sendToolsCall() { + reached(); + if (hold) await hold; + 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 }) }] }; + }, + async sendChannelsOpen(params: { channelId?: string }) { + opens.push(params); + if (refuseOpens) throw new Error('open refused by server'); + return {}; + }, + async sendChannelsClose() { + if (refuseCloses) throw new Error('close refused by server'); + 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' || id === 'discord2' ? server : undefined), + getAllServers: () => [server], + } 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 openIfClosedForSend = registry.openIfClosedForSend.bind(registry); + registry.openIfClosedForSend = ((...args: Parameters) => { + const opening = openIfClosedForSend(...args); + speechOpens.push(opening); + return opening; + }) as typeof openIfClosedForSend; + await registry.handleChanged('discord', { + added: [{ id: channelId, type: 'discord', label: '#upkeep', direction: 'bidirectional', + address: { guildId: 'g1', channelId: '444555' } }], + } as never); + + const framework = Object.create(AgentFramework.prototype) as any; + framework.mcplServerRegistry = serverRegistry; + framework.mcplServerConfigs = new Map([['discord', { id: 'discord', toolPrefix: 'discord' }]]); + framework.mcplTools = []; + framework.mcplToolFeatureSets = new Map(); + framework.checkpointManager = null; + framework.channelRegistry = registry; + // The sending turn's routing, whose engaged set a send tool made in that + // turn (callerTurnId) records into. + framework.turnRouting = new Map([['sol', { turnId: 't1', engaged: new Set() }]]); + framework.openLogical = new Map(); + framework.scriptCallStarts = new Map(); + framework.usedEphemeralAgentNames = 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', agentStub()]]); + // Conversation agents by their home channel; sol isn't one. + framework.conversationAgentHomes = new Map(); + const windowMessages: 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 a send tool by the raw id, made in the agent's turn; resolve + * 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; raw: unknown; error: string }> { + if (script) framework.scriptToolWaiters = new Map([[callId, () => {}]]); + 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) { + clearTimeout(timeout); + resolve({ + success: event.result.success, + data: String(event.result.data ?? ''), + raw: event.result.data, + error: String(event.result.error ?? ''), + }); + } + }; + framework.dispatchMcplToolCall( + 'sol', + { id: callId, name: `discord--${tool}`, callerTurnId: 't1', + input: { channelId: sentTo, messageId: 'm1', content: 'answering, without moving in' } }, + 'discord', + 'discord', + ); + }); + // Let the open the send started on its own settle, and what follows it, + // before the caller looks. + await Promise.allSettled(speechOpens.splice(0)); + await new Promise((resolve) => setImmediate(resolve)); + framework.scriptToolWaiters = undefined; + return { ...out, engaged: [...(framework.turnRouting.get('sol')?.engaged ?? [])] }; + } + + await framework.refreshMcplTools(); + return { + registry, framework, opens, windowMessages, windowFor, reply, published, + setRefuseOpens: (v: boolean) => { refuseOpens = v; }, + 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; }, + 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, + }; +} + +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); + + 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"); + // 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(); + 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 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); + 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 who closed the channel, and then a lookup + // throws something that isn't an Error at all. + 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.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.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 = { + 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, receiptRouting(channelId)); + 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("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(ref(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("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); + 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(ref(channelId)), 'closed'); + assert.equal(registry.isChannelOpen(ref(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); + 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 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(ref(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(ref(channelId)), false); + assert.equal(registry.getDesiredState('discord', channelId), before, 'and no intent to open is recorded'); + assert.equal(registry.speechOpens(ref(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. + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId, serverId: 'discord2' }, origin)).success, true); + assert.equal(registry.isChannelOpen(ref(channelId, 'discord2')), true); + // 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(ref(channelId)), false); + const { success, data } = await reply(); + assert.equal(success, true); + // Two servers register the id, so the agent is shown its server-qualified name. + assert.ok(data.endsWith(UPKEEP_NOTE.replace(`(${channelId})`, `(discord:${channelId})`)), 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(); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId }, origin)).success, true); + assert.ok(!(await reply()).data.includes('[closed channel]'), 'open'); + } + { + // 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(ref(channelId)), false); + } +}); + +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, engaged } = await reply(); + assert.equal(success, true); + assert.ok(!data.includes('[closed channel]'), `${id}: no note where the send opens the channel`); + assert.equal(engaged.at(-1), destinationKey(ref(id)), 'recorded as engaged'); + assert.equal(registry.isChannelOpen(ref(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 opened it. ` + + 'What an open channel brings you is up to its server: some send every message in it, and others only ' + + "what addresses you unless you're subscribed to it; their own tools can change that. " + + '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, the subscription policy nor its server's own default opens it again."); + } + 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', + proseRouting: 'auto', + getContextManager: () => ({ + addMessage: (_role: string, content: Array<{ text: string }>) => { lines.push(content[0]!.text); return 'm'; }, + }), + }; + const receipt = (...deliveries: string[]) => { + framework.appendProseDeliveryReceipt(agent, receiptRouting(...deliveries)); + 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}`); + // 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') + .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 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})`); + 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. + 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, engaged } = await reply(); + + assert.equal(success, true); + assert.equal(opens.length, opensBefore, 'the send sent no channels/open'); + assert.equal(registry.isChannelOpen(ref(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(engaged, [destinationKey(ref(channelId))], 'recorded as engaged this turn, though it stays closed'); +}); + +test('a reply_message into a channel decided open whose open failed opens it again, as on main, and the decision stands', 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(ref(channelId)), false); + setRefuseOpens(false); + const opensBefore = opens.length; + + const { success, data } = await reply(); + + assert.equal(success, true); + 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(ref(channelId)), true); + assert.equal(registry.getDesiredState('discord', channelId), 'open'); + assert.deepEqual(windowMessages, [], 'reconfirming a channel already decided open changes no decision, so no notice'); +}); + +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. ' + + `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); + 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'); + // 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); + 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', true); + 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', true)).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', true); + 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', true)).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); + let release!: () => void; + setHold(new Promise((resolve) => { release = resolve; })); + const opensBefore = opens.length; + const replying = reply(); + // 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 { success, data } = await replying; + assert.equal(success, true); + assert.equal(registry.isChannelOpen(ref(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 () => { + 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, /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. + 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 }); + } +}); + +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, setGrant } = await harness(); + await registry.handleChanged('discord', { + 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(); + framework.timeZone = 'UTC'; + const routing = framework.turnRouting.get('sol'); + // Where each announcement was routed, and its post, which the sleep doesn't wait for. + const routedTo: Array<{ serverId: string; channelId: string } | null> = []; + const posts: Promise[] = []; + const route = registry.routeSpeech.bind(registry); + registry.routeSpeech = ((...args: Parameters) => { + routedTo.push(args[2] as { serverId: string; channelId: 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 }) => { + if (event.type === 'tool-result' && event.callId === 'call-sleep') resolve(event.result); + }; + framework.dispatchSleepToolCall('sol', { id: 'call-sleep', name: 'sleep', callerTurnId: 't1', input }); + }); + 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."; + 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 #upkeep, which the agent closed. + assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); + routing.locus = ref(channelId); + let slept = await sleep({ seconds: 600 }); + assert.equal(slept.success, true); + assert.equal(slept.endTurn, true); + 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(ref(channelId)), false, 'it stays closed'); + + // The agent's own words are posted as they are. + slept = await sleep({ seconds: 600, message: 'back soon' }); + 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.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + framework.closedChannelNoteSettingsExtension().reset('sol'); + + // When who closed the channel can't be read, the words are ones true either way. + const speechLeavesClosed = registry.speechLeavesClosed.bind(registry); + registry.speechLeavesClosed = () => { throw new Error('the registry is unreadable'); }; + slept = await sleep({ seconds: 600 }); + 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. + 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 = ref('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(ref('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(ref('discord:g1:343434')), 'closed', 'the close is on record'); + assert.equal(registry.isChannelOpen(ref('discord:g1:343434')), true, 'and the channel is still open'); + routing.locus = ref('discord:g1:343434'); + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId: 'discord:g1:343434', text: quiet }]); + routing.locus = ref(channelId); + + // 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.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + + // Hybrid routing announces to the locus as locus mode does. + framework.agents.set('sol', agentStub({ proseRouting: 'hybrid' })); + slept = await sleep({ seconds: 600 }); + assert.deepEqual(published.splice(0), [{ channelId, text: addressed }]); + framework.agents.set('sol', agentStub()); + + // A closed channel nobody decided about: the announcement opens it, as speech does, so all of it reaches the agent. + routing.locus = ref('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(ref('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); + + // A locus the registry doesn't know: the post fails, as before. + routing.locus = ref('discord:g1:999999'); + slept = await sleep({ seconds: 600 }); + assert.deepEqual(routedTo.splice(0), [ref('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 = ref(channelId); + + // Prose routing off, a silent turn, or no announcement: nothing is routed. + framework.agents.set('sol', agentStub({ proseRouting: 'disabled' })); + slept = await sleep({ seconds: 600 }); + framework.agents.set('sol', agentStub()); + framework.activeTurnTriggers.set('sol', { suppressProse: true }); + slept = await sleep({ seconds: 600 }); + framework.activeTurnTriggers.delete('sol'); + slept = await sleep({ seconds: 600, announce: false }); + assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); + + // Explicit mode announces only to the turn's target: none routes nothing. + framework.agents.set('sol', agentStub({ proseRouting: 'explicit' })); + slept = await sleep({ seconds: 600 }); + assert.deepEqual(routedTo.splice(0), [], 'nothing was routed'); + assert.deepEqual(published, [], 'nothing was posted'); + 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 () => { + 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); + 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 { 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); + }; + 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/shared-channel-id.test.ts b/test/shared-channel-id.test.ts index 91ded35a2..a6d344d39 100644 --- a/test/shared-channel-id.test.ts +++ b/test/shared-channel-id.test.ts @@ -243,7 +243,7 @@ for (const order of orders) { assert.equal(f.registry.getDesiredState('a', 'general'), undefined, "a's channel is another channel"); // a's general is open, so there's nothing to decline there: the refusal names it apart. const open = await f.registry.handleChannelToolCall('channel_decline', { channelId: 'a:general', messageId: 'm2' }); - assert.match(open.error ?? '', /^Channel "a:general" is open, or set to open, so there's nothing to decline/); + assert.match(open.error ?? '', /^Channel "a:general" is open, so there's nothing to decline/); }); test(`a >> target names one server's channel by its qualified id, and a shared id or label lists them apart (${registered})`, () => { @@ -952,7 +952,7 @@ test("a closed-channel invitation offers no >> reply a line reads as private spe }); assert.ok(invite, 'control: an addressed message from a closed channel gets an invitation'); const text = JSON.stringify(invite); - assert.ok(text.includes('Replying with a \\">>\\" prefix isn\'t available here'), text); + assert.ok(text.includes('no single-word target reaches this channel unambiguously'), text); assert.ok(!text.includes('>>skip_reply'), text); // The real registry offers the channel by its server-qualified name, which // a >> line reads as a target. diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 34519328f..f2b622cc5 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -163,6 +163,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), inboundOnlyServer: () => undefined, @@ -203,12 +205,15 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.match(text, /Doing nothing is also fine\./); // #406: what else reaches the agent is each server's delivery (slack-mcpl // keeps its own subscription), so the invitation says only what holds on - // every server, and that a reply opens the channel, as routeSpeech does. + // every server. assert.match( text, /a channel you haven't opened\. Messages there that address you, such as an @-mention, reach you whether or not you open it\./, ); - assert.match(text, /prefixed with ">>#portables"; it will be delivered there, which opens the channel\./); + // Here nobody decided to keep the channel closed, so a reply opens it, as + // speech does, and the invitation says so, with how to reply without + // opening it. + assert.match(text, /\n1\. Reply — write your reply this turn prefixed with ">>#portables"; it will be delivered there, 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/); assert.match(text, /2\. Open the channel — call channel_open/); assert.doesNotMatch(text, /invisible to you|follow-ups to your reply|without joining/); // No surface-tracked tally on this event → no missed-count sentence. @@ -222,6 +227,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), inboundOnlyServer: () => undefined, @@ -289,6 +296,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { 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'; }, @@ -309,6 +318,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, @@ -318,7 +329,9 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.handleMcplPushEvent(dmEvent('m4')); const t2 = textOf(fw2); assert.doesNotMatch(t2, /prefixed with ">>/); - assert.match(t2, /Replying with a ">>" prefix isn't available here: .* Open it \(option 2\) to reply\./); + 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 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/, + 'the hint promises no tool, and says what replying does'); assert.match(t2, /channel_open with channelId "discord:dm:1555"/, 'opening stays available'); await fw2.stop(); @@ -328,6 +341,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i3.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getChannelLabel: () => undefined, getDescriptor: () => undefined, inboundOnlyServer: () => undefined, @@ -338,6 +353,91 @@ describe('Trunk channel routing (item-3 redux)', () => { await fw3.stop(); }); + it('in a channel the agent closed or declined itself, the invitation says a reply leaves it closed', 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: (c: { serverId: string; channelId: string }) => { asked.push(`${c.serverId}/${c.channelId}`); return false; }, + getDesiredState: () => 'closed', + getChannelLabel: () => undefined, + getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), + proseTargetFor: () => target, + inboundOnlyServer: () => undefined, + 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'); + // What else reaches the agent from a channel left closed is its server's + // to say (#406), so the line says only that the channel stays closed. + assert.match(text, target + ? /\n1\. Reply — write your reply this turn prefixed with ">>#portables"; it will be delivered there, and the channel stays closed\.\n/ + : /\n1\. Reply — 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\. Replying there leaves the channel closed\.\n/); + assert.doesNotMatch(text, /opens the channel|decline it first|follow-ups to your reply/); + await framework.stop(); + } + }); + + it('a direct message carries no invitation: all of a DM reaches the agent, open or closed', async () => { + // 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 = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, + getChannelLabel: () => undefined, + getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), + proseTargetFor: () => '@_reim0n', + inboundOnlyServer: () => undefined, + 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('the invitation offers no reply into a channel its server declared inbound-only, and says why', async () => { // pocket-body's live transcript: another agent speaking in the room arrives // on it addressed, and the host publishes nothing into it, so a ">>" reply would @@ -347,6 +447,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getDescriptor: () => ({ label: 'Body · ears', direction: 'inbound' }), getChannelLabel: () => 'Body · ears', proseTargetFor: () => '#ears', @@ -394,6 +496,8 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + speechOpens: () => true, + getDesiredState: () => undefined, getDescriptor: (c: { channelId: string }) => ({ label: labels[c.channelId] }), getChannelLabel: (_serverId: string, id: string) => labels[id], inboundOnlyServer: () => undefined, @@ -476,7 +580,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const one = lastText(); assert.match( one, - /Now open: #general \(acme\) \(slack:C1\): your own message into the closed channel opened it\. What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that\. If you don't want it, use channel_close: neither the subscription policy nor its server's own default opens it again\./, + /Now open: #general \(acme\) \(slack:C1\): your own message into the closed channel opened it\. What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that\. 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, the subscription policy nor its server's own default opens it again\./, ); assert.doesNotMatch(one, /will reach you/); @@ -484,7 +588,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const two = lastText(); assert.match( two, - /Selected for opening by subscription policy: slack:C1, slack:C2\. The host opens them, and if one doesn't open, tries again when its server next registers it; channel_list shows whether each is open yet\. What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that\. If you don't want one of them, use channel_close: neither the subscription policy nor its server's own default opens it again\./, + /Selected for opening by subscription policy: slack:C1, slack:C2\. The host opens them, and if one doesn't open, tries again when its server next registers it; channel_list shows whether each is open yet\. What an open channel brings you is up to its server: some send every message in it, and others only what addresses you unless you're subscribed to it; their own tools can change that\. If you don't want one of them, use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again\./, ); assert.doesNotMatch(two, /will reach you/); await framework.stop();