diff --git a/changelog.d/publish-for-agent-shared-id.added.md b/changelog.d/publish-for-agent-shared-id.added.md new file mode 100644 index 000000000..10f5f9af6 --- /dev/null +++ b/changelog.d/publish-for-agent-shared-id.added.md @@ -0,0 +1,4 @@ +- `PublishRefusalReason` gains `shared-id`: `publishForAgent` refuses a name + that means more than one registered channel, before anything goes out. A + ref picks one, as a server-qualified name does where it names one channel + alone. diff --git a/changelog.d/shared-channel-ids.breaking.md b/changelog.d/shared-channel-ids.breaking.md new file mode 100644 index 000000000..39325f508 --- /dev/null +++ b/changelog.d/shared-channel-ids.breaking.md @@ -0,0 +1,38 @@ +- **Code that calls `ChannelRegistry` or `ConversationRouter` directly:** a + channel the registry acts on is a `ChannelRef`, `{ serverId, channelId }` + (exported as a type), where it was a bare channel id, since two MCPL + servers can register the same id. + - `routeSpeech` and `deliverSpeech` take the locus as a `ChannelRef` (or + `null`), and `resolveLocus` and `getDefaultPublishChannel` return one. + `getDescriptor`, `isChannelOpen`, `inboundOnlyServer` and + `proseTargetFor` take one. `getChannelServerId` is removed: a ref names + its server. + - `buildChannelContext(agentName, serverId)` builds the channel context one + server is told, and `incomingFor(agentName)` gives the message the agent + is replying to, with its channel. + - `ChannelRegistryOptions.homeChannelResolver` and `activeChannelResolver` + return a `ChannelRef`. + - `sendOutgoingChunk` and `sendOutgoingComplete` take only the + destination the publish went to. A bare channel id, which they resolved + again at each call and dropped where two servers register it, is no + longer taken. + - `ConversationRouter`'s `bind`, `touch`, `unbind` and `getBinding` take a + `ChannelRef`, `channelForAgent` returns one, and a `ConversationBinding` + carries `serverId`. + - **Migration:** pass `{ serverId, channelId }`. An inbound event, a + publish outcome's `destination` and `channel_list` each carry the server. + `resolveProseTarget`'s answer, `openIfClosedForSend`'s, `channel_open`'s + result data, a gate wake's `WakeProvenance` (as `routeServerId`) and + `onRouteFailure`'s info now carry it too, and `InferenceRequest` carries + it as `channelServerId`, beside `channelId`. A stream's chunk and + `complete` take the publish outcome's `destination`. For a name given as a + string, `channelsNamed(name)` gives the channels it means, by its id or + its `serverId:channelId` form, and `nameShown(ref)` the name an agent is + shown for a channel. + - **Unchanged:** `startTyping`, `stopTyping` and `publishForAgent` still + accept a bare id, as connectome-host's activity and notices modules pass + one, and also take a ref or a `serverId:channelId` name. Where two + servers register the id, what a bare id does changes, as Fixed says: + typing shows in each server's channel, and `publishForAgent` refuses it. + `InferenceRequest.channelId` stays. No tool's description or schema + changes. diff --git a/changelog.d/shared-channel-ids.changed.md b/changelog.d/shared-channel-ids.changed.md new file mode 100644 index 000000000..1755a1862 --- /dev/null +++ b/changelog.d/shared-channel-ids.changed.md @@ -0,0 +1,41 @@ +- Each MCPL server is told only of its own channels in + `context/beforeInference`'s channel context (SPEC §14.4): `incoming` and + `defaultOutgoing` only when that channel is the server's, and `candidates` + only its own open channels. Every capable server was told the same + context, other servers' channels included, by their ids alone, which a + server registering the same id would read as its own. A server that read + other servers' channels from it no longer hears of them. +- A `[prose-routing]` bounce that lists the channels its target could mean + gives its resend examples with the first of them written as one token, + such as `>>a:general {{unsent}}` and `destination: "a:general"`, where it + gave `>>#channel` and `"#channel"` whatever it listed. +- The `mcpl:conversation-spawned`, `-spawn-failed`, `-unrouted`, + `-binding-orphaned`, `-closed` and `-disposed` traces carry the channel's + `serverId`, and a refusal's failure log names its locus as the agent is + shown it. +- `inference:started` carries the server of its `channelId` as `serverId`, + so a host that posts back to the turn's channel can name it where two + servers register the id. `inference:tokens` and `inference:content_block` + carry the id alone. +- `channel_list` gives a channel whose id names another channel too, another + server's channel with that id or another channel's server-qualified id, a + `name`: its server-qualified id, where that names it alone, which + `channelId` takes, and a `>>` target too where it is one token. Other rows + are as they were. +- The results of `channel_open`, `channel_close` and `channel_decline` carry + the channel's `serverId` in their `data`. +- An open that a tune-out overtook ends as `channel_open`'s refusal of a + tuned-out channel does, saying how the tune-out ends: by cancelling it, + which delivers the diverted backlog, or, for a channel no name picks + alone, by itself. +- A fork's channel refusals quote its channel and what the call asked for. +- A `serverId` given to `channel_publish`, whose schema has none, is read + beside a `channelId` as that exact pair, and with no `channelId` as the + default channel's id on that server, where it was ignored. +- A drafts resend whose destination names a channel that takes no posts is + refused before anything is sent, as a `>>` target naming one is; before, + the send refused its first draft. +- In explicit mode, a start whose wake names no channel, such as a budget + restart or a re-inference, keeps typing in the channel the turn shows, + starting it again where a stop or a failed start ended it, and its token + traces carry that channel's id, where they carried none. diff --git a/changelog.d/shared-channel-ids.fixed.md b/changelog.d/shared-channel-ids.fixed.md new file mode 100644 index 000000000..96d0e9ae4 --- /dev/null +++ b/changelog.d/shared-channel-ids.fixed.md @@ -0,0 +1,99 @@ +- Two MCPL servers can register the same channel id (MCPL requires one to be + unique only within a server's connection), and the framework now tells + their channels apart wherever it acts on one, instead of taking whichever + server registered the id first, or refusing the id outright. + - A turn's plain speech, its typing indicator and its outgoing stream go to + the channel on the server the turn's message came from. Speech to a + shared id was refused as ambiguous, with a failure marker, and typing + showed on the server that registered the id first. The `[routing]` and + `[channels]` notices, the turn's receipt, a bounce, the drafts' + destinations and the channel tools' results and refusals name such a + channel by its server-qualified id, `serverId:channelId`. + - `channel_publish` and the subconscious's `speak_in_channel` refuse a + shared id, naming each candidate by its server-qualified id, and take + that form in `channelId` to name one server's channel. Both posted on the + server that registered the id first. `tune_out` does the same, where it + tuned out the channel on the server that registered the id first, and its + result names the channel as the agent can name it back. + `channel_open`, `channel_close` and `channel_decline` take the form too, + alone, and a `serverId` beside a `channelId` names that server's channel + with that exact id. The refusal names each candidate so it can be told + apart, without "not found". Ids hold colons, so two channels' + server-qualified ids can be one string, as `a`'s `b:room` and `a:b`'s + `room` are: such a name is refused as a shared id is, and a channel no + string picks alone is named by its id and server, `"b:room" on server + "a"`. No tool's description or schema changes. + - The subconscious's `cancel_tuneout` reads a name as `tune_out` does: it + refuses a name two registered channels answer to, naming both, and takes + the server-qualified form, where it cancelled the one on the server that + registered the id first. Its `deliver_summary` refuses such a name too, + and tags the summary with the channel's server, where it stored the id + alone. Tune-out's notices, and the reasons its wakes + give, name such a channel by its server-qualified id, and `tune_out` + refuses a channel because a conversation is bound to it only when that + conversation is bound to that server's channel. + - A `>>` or `>>>` target takes the server-qualified id, and a shared id or + label is refused with its candidates named so each can be told apart. + `>>b:general` matched nothing, and a shared label's candidates read as + the same id twice. `prose_help`'s reference shows the form. A `<@id>` + mention or an `@name` that one person's DMs on two servers answer to is + refused with both named apart, where a mention was refused as matching + no DM. A closed-channel invitation for a channel whose id another server + shares offers a `>>` reply naming its server. + - A host's bare id to `startTyping` or `stopTyping` types in each server's + channel with that id, so connectome-host's activity indicator shows in + both, rather than on the first registered server. Where two servers reach + one platform channel, two bots can show typing there. + - `publishForAgent` refuses a shared bare id, where it posted on the first + registered server. A host that passes the server as a ref is answered + there, as one that passes `serverId:channelId` is where that names one + channel alone. + - A push's channel is registered for the server that pushed it, whatever + another server registers, and its reply goes back through that server. + A push leaves the entry of another server's channel with that id as it + was. + - Within a turn, a `>>` or `>>>` target, a drafts resend's destination, + and the `channelId` of `channel_publish`, `channel_open`, + `channel_close`, `channel_decline`, `tune_out` and the subconscious's + `cancel_tuneout`, `speak_in_channel` and `deliver_summary`, that name + the id of a channel the turn has been in (the channel of the message it + answers, and any a mid-turn message or `channel_open` moved it to) mean + that channel on its server, and, where two of the turn's channels on + two servers have that id, neither, with each named. Where another + server registers the same id, such a reply + goes back where the message came from, where it was refused as a shared + id; once that channel's server no longer registers it, the call is + refused, where it went out through, or acted on, another server's + channel with the id. A tool call takes the turn's channels only + when it is made inside the turn, and a script's call only when the turn + that started the script is the one waiting on it, so a script left + running after its turn takes none, and a later turn's channels aren't + lent to it. + - `channel_publish` with no `channelId` posts to its default channel, the + latest message's, on the server that message came from, and is refused + once that server no longer registers the channel, where it went out + through another server that registers the id. The refusal names no other + channel to post to, since the one with that id that remains is another + server's. + - A wake that names its channel without the server, such as a host's + inference request, by an id two servers share or that no registered + channel has, gives the turn no channel and no fallback. Its speech isn't + published, as before, and the agent is no longer told its speech lands in + the first registered server's channel. + - The deprecated conversation routing gives each server's channel its own + fork, where a second server's channel with the same id reached the first + one's fork. A fork's `channel_publish` and `channel_close` of its home's + id act on its home, on its server, where they acted on the first + registered server's channel, and a home its server no longer registers is + refused as such. An addressed wake's route applies to the fork homed on + that server's channel only. + - Coalesced work for a channel is admitted only while its own server + registers the channel, not another server's channel with the same id. + - A batched gate wake's summary lists two servers' channels with one id + apart, each with its count, where it merged them into one line under + the first one's label. + - Two servers' wakes on one channel id at one moment are two wakes to a + provider hold, where one was dropped as the other's duplicate. + - The registry keeps each channel under its server and id together, so + server `a`'s channel `b:room` and server `a:b`'s channel `room`, which a + key joined with a colon gave one entry, are two. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 8db5f6f99..b8e742880 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -17,10 +17,28 @@ A prose segment's FIRST line may carry a routing prefix: ``` >>#channel-name send to the channel with this label >>@person send to the DM with this person ->>discord:guild:id send by exact channel id (always unambiguous) +>>discord:guild:id send by exact channel id +>>srv:discord:guild:id the same channel id on the MCPL server `srv`, where more than one server registers it >>skip_reply do not send; text stays in context only (mirrors the skip_reply tool) ``` +A channel id is unique only within one MCPL server's connection, so two +servers can register the same one. A target that more than one channel +answers to is refused, with its candidates named so each can be told apart: +by its id, or, where its id is shared, in the server-qualified form, or, where +that is shared too (ids hold colons), by its id and server, which no target +takes. The server-qualified form is looked up among the registered pairs, +never split at a colon. Within a turn, an id that one of the turn's own +channels has, a channel the turn has been in (the channel of the message it +answers, and any a mid-turn message or channel_open moved it to), means that +channel, on its server, even where another server registers the id, and is +refused once that server no longer registers it: the words go only where the +message came from, never through another server that has the id. Where two of +the turn's channels have that id, on two servers, it means neither, and is +refused with each named. The channel tools, tune_out and the subconscious's +cancel_tuneout, speak_in_channel and deliver_summary read a name the turn gives +them the same way. + - Body may start on the same line (`>>#ops deploy done`) or the next. - **Sticky within the turn:** the latest resolved target applies to later unprefixed segments of the SAME turn, including those a later stream of it diff --git a/src/code-execution/isolate/scripts.ts b/src/code-execution/isolate/scripts.ts index 467743885..39654f6a0 100644 --- a/src/code-execution/isolate/scripts.ts +++ b/src/code-execution/isolate/scripts.ts @@ -46,6 +46,8 @@ export interface ScriptCallContext { watch?: ScriptWatch; /** The call watching the script now, if one is: a result that ends the turn ends it at that call's return. Without it, `watch`. */ watching?(): ScriptWatch | undefined; + /** The turn the script's run call was made in, when one was: the turn its words were written in, which a later turn's wait doesn't change. */ + startTurnId?: number; } export interface ScriptsHost { @@ -231,6 +233,7 @@ export class AgentScripts { ...script.context, ...(script.watch ? { watch: script.watch } : {}), watching: () => script.watch ?? undefined, + ...(script.turnId !== undefined ? { startTurnId: script.turnId } : {}), }), onWake: (payload, n) => this.wake(script, payload, n), onEnd: (ending) => this.ended(script, ending), diff --git a/src/framework.ts b/src/framework.ts index 9a6b73187..368132d01 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -100,10 +100,10 @@ import { } from './mcpl/push-coalescer.js'; import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, McpToolResultContent, PushEventResult } from './mcpl/types.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHOLDER } from './mcpl/visible-content.js'; -import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; +import { isProseTargetToken, parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; +import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; @@ -620,6 +620,11 @@ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled', hel const PROSE_ROUTING_HELP = 'Plain-text output routing (explicit mode):\n' + ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' + + ' Where two servers register one channel id, the server goes first:\n' + + ' >>server:service:guild:id … (a bounce lists each such name).\n' + + ' Within a turn, the id of a channel the turn has been in means that channel,\n' + + ' on its server, and bounces once that server no longer registers it; where\n' + + ' two of the turn\'s channels have the id, it means neither and bounces.\n' + ' One message may hold several sections: each line starting with ">>" begins\n' + ' a new envelope, routed to its own destination independently.\n' + ' A destination applies to the rest of that turn, until another is named.\n' + @@ -688,6 +693,7 @@ import type { McplHostCapabilities, FeatureSetsChangedParams, BeforeInferenceParams, + ChannelContext, PushEventParams, McplInferenceRequestParams, InferenceChunkParams, @@ -964,7 +970,7 @@ function splitExplicitEnvelopes(rawText: string): string[] { */ interface AdmittedEnvelope { text: string; - resolved?: { channelId: string } | { error: string; candidates?: string[]; refusedChannelId?: string }; + resolved?: ChannelRef | { error: string; candidates?: string[]; refusedChannelId?: string }; } /** The domains an mcpl/manifestChanged announcement names, for its receipt: @@ -1030,7 +1036,15 @@ interface TurnRouting { * a budget restart's trigger carries no channel, and dropping it there * sent Sol's DM reply to a stale guild channel (2026-07-22). */ - triggerChannel?: string; + triggerChannel?: ChannelRef; + /** + * The turn was woken for a channel that names no one registered channel (a + * host's id, given without its server, that two servers' channels answer + * to, or that none does), so it has no channel and takes no fallback, in + * its locus or in what servers are told (activeChannelResolver's null). + * A fork's home still applies. + */ + noChannel?: true; /** * Locus and hybrid mode: where the turn's unpinned plain prose lands. * Frozen when the turn opens (home channel for a fork, else the trigger @@ -1041,7 +1055,14 @@ interface TurnRouting { * moves it too. Unset in explicit and disabled mode, and for a turn with * nowhere to speak. */ - locus?: string; + locus?: ChannelRef; + /** + * Every channel that has been the turn's trigger or its locus, in the + * order each became so: a channel_open or a re-pin moves those two, and + * the channels they leave stay here. A channel the turn has been in keeps + * its server for the names the turn gives (ownChannels). + */ + wasIn: ChannelRef[]; /** * The channel the turn's typing indicator was last shown in, by any of its * streams (showTurnTyping). A stream that hands the turn to a successor (a @@ -1049,16 +1070,17 @@ interface TurnRouting { * start, or a move, that shows it elsewhere stops this one first, as after * a re-pin. */ - typing?: string; + typing?: ChannelRef; /** * Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a * conversational follow-up in one of them is addressed by context, so it * re-pins the locus (2026-07-31 n=7: q's #portables reply, with no - * mention, followed the stale pin into repligate's DM). + * mention, followed the stale pin into repligate's DM). Keyed by + * destinationKey. */ engaged: Set; /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */ - pin?: string; + pin?: ChannelRef; /** * Hybrid: the router is fail-closed until a later `>>>` names a target that * resolves. After a `>>>skip_reply` envelope ('private') the rest stays @@ -1069,7 +1091,7 @@ interface TurnRouting { /** The turn asked for a ` !` continuation; taken once. */ continuation: boolean; /** Successful plain-prose destinations in delivery order; deduped by the receipt. */ - deliveries: string[]; + deliveries: ChannelRef[]; /** Plain-prose segments the turn suppressed without drafts (proseRouting=disabled, or a round withheld before its scripts returned: ScriptRound.withhold), reported by its receipt. */ suppressed: number; /** @@ -1108,16 +1130,22 @@ interface TurnRouting { interface Arrival { messageId: MessageId; /** The channel it re-pins the locus to. */ - channelId: string; + channel: ChannelRef; } /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */ let lastTurnId = 0; +/** Record that a channel has been the turn's trigger or its locus (TurnRouting.wasIn). */ +function noteTurnChannel(routing: TurnRouting, channel: ChannelRef | undefined): void { + if (channel && !routing.wasIn.some((c) => sameChannel(c, channel))) routing.wasIn.push(channel); +} + function freshTurnRouting(): TurnRouting { return { turnId: ++lastTurnId, engaged: new Set(), + wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; } @@ -2403,12 +2431,6 @@ type ChannelNoticePassOn = | { notice: 'channel-opened'; serverId: string; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } | { notice: 'reply-open-failed'; serverId: string; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; -/** A channel as the channel notices show it (the opened and reply notices, - * and the lines that log them): its label beside its id, or its id alone. */ -function shownChannel(channel: { channelId: string; label?: string }): string { - return channel.label && channel.label !== channel.channelId ? `${channel.label} (${channel.channelId})` : channel.channelId; -} - /** A channel as a notice's inputs carry it, read back from stored metadata. */ function noticeChannel(value: unknown): { channelId: string; label?: string } | null { const channel = value as { channelId?: unknown; label?: unknown } | null | undefined; @@ -2578,7 +2600,7 @@ export class AgentFramework { * window message. Announce-on-change only: steady state emits nothing * (KV-safe, no per-turn chatter). In-memory — after a process restart the * first turn's locus is announced once to re-establish the baseline. */ - private lastAnnouncedLocus: Map = new Map(); + private lastAnnouncedLocus: Map = new Map(); // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- /** Every resident's held drafts: plain speech that was suppressed, bounced @@ -2929,7 +2951,7 @@ export class AgentFramework { * expire): publish/injection scoping must survive unbinding so the closure * turn still lands in the right channel. */ - private conversationAgentHomes: Map = new Map(); + private conversationAgentHomes: Map = new Map(); /** Forks being spawned, by channel id, each until its spawn settles (see * routeConversation). The promise never rejects. */ private readonly conversationSpawns = new Map>(); @@ -2946,6 +2968,10 @@ export class AgentFramework { // dispatchToolCall path. private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null; private scriptToolWaiters: Map void> = new Map(); + /** A script's call being dispatched, by its call id: the turn the script + * was started in, if one was (callOwnChannels). Kept only while the + * dispatch runs (dispatchScriptToolCall). */ + private readonly scriptCallStartTurns = new Map(); /** Per agent, a round that called code_execution and waits for those calls' * results before its prose is routed (ScriptRound). */ private scriptRounds: Map = new Map(); @@ -3440,9 +3466,14 @@ export class AgentFramework { // route applies to a fork only when it IS that home, and never to // another agent when a fork owns that channel. Otherwise a fork // bound elsewhere would show typing where it isn't replying. - ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId) - ? { channelId: provenance.routeChannelId, addressed: true } - : {}), + ...((): Partial => { + const route = provenance?.routeChannelId !== undefined + ? framework.channelNamed(provenance.routeChannelId, provenance.routeServerId) + : undefined; + return route && framework.mayTakeGateRoute(agentName, route) + ? { channelId: route.channelId, channelServerId: route.serverId, addressed: true } + : {}; + })(), }); }, getAgentNames: () => [...framework.agents.keys()].filter( @@ -3451,6 +3482,9 @@ export class AgentFramework { // them to the registered composite id the same way ingestion does // (handleMcplPushEvent registers that channel on arrival, before any // debounce can fire). + // A batched wake's summary names each channel as the agent names it + // back, with its server where another server shares its id. + channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? channel.channelId, resolveRouteChannel: (info) => { if (info.eventType === 'mcpl:channel-incoming') return info.channelId || undefined; if (info.eventType === 'mcpl:push-event') { @@ -3637,8 +3671,8 @@ export class AgentFramework { currentSequence: () => framework.store.currentSequence(), setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence), - isForkBound: (channelId) => - [...framework.conversationAgentHomes.values()].includes(channelId), + isForkBound: (channel) => + [...framework.conversationAgentHomes.values()].some((home) => sameChannel(home, channel)), isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false, allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true, @@ -4573,7 +4607,8 @@ export class AgentFramework { return { delayMs: ownMs, ownMs, stated: false }; } /** Whether two requests are one wake, for a provider hold's dedup: the - * same agent, reason, source, time and channel, and the same coalescing + * same agent, reason, source, time and channel, on the same server (two + * servers' channels can share an id), and the same coalescing * marks: a batch wake's subject, a batch-only merge's subjects (it has no * single subject), and the write a wake reads. A wake with any of them * can be withdrawn or dropped on its own, and must not stand for one @@ -4581,7 +4616,7 @@ export class AgentFramework { private sameInferenceRequest(a: InferenceRequest, b: InferenceRequest): boolean { const batchSubjects = (r: InferenceRequest) => (r.coalescingBatchSubjects ?? []).join('\n'); return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source && - a.timestamp === b.timestamp && a.channelId === b.channelId && + a.timestamp === b.timestamp && a.channelId === b.channelId && a.channelServerId === b.channelServerId && a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) && a.readsDeferredWrite === b.readsDeferredWrite; } @@ -7627,7 +7662,7 @@ export class AgentFramework { const hookParams = this.buildBeforeInferenceParams(agent); const hookInjections = this.scopeInjectionsForAgent( agentName, - await this.hookOrchestrator.beforeInference(hookParams), + await this.hookOrchestrator.beforeInference(hookParams, (serverId) => this.channelsForServer(agentName, serverId)), ); if (hookInjections.length > 0) { injections = injections ? [...injections, ...hookInjections] : hookInjections; @@ -9160,25 +9195,24 @@ export class AgentFramework { // The refused agent's own view of the latest inbound message: an // ephemeral run has none (takesGlobalFallback), so a subagent's refusal // marks no one else's channel. - const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; + const incoming = this.channelRegistry?.incomingFor(agentName); if (!incoming) return; - // incoming.channelId is the MCPL composite id ("discord::"); + // The channel id is the MCPL composite id ("discord::"); // the reaction tool wants the raw Discord channel (or thread) id — the // last segment. - const parts = incoming.channelId.split(':'); + const parts = incoming.channel.channelId.split(':'); if (parts[0] !== 'discord') return; const channelId = parts[parts.length - 1]; // Own names only: an inherited one such as 'constructor' takes the fallback. const emoji = Object.hasOwn(REFUSAL_REACTIONS, category) ? REFUSAL_REACTIONS[category] : REFUSAL_REACTION_FALLBACK; - // Resolve the MCPL server that owns the locus channel and call - // tools/call directly on its connection (bare tool name — no prefix - // games), bypassing the agent event queue so no synthetic tool-result - // is injected into a turn the agent never took. - const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId); - const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null; + // Call tools/call directly on the connection of the server whose + // channel the message came in on (bare tool name — no prefix games), + // bypassing the agent event queue so no synthetic tool-result is + // injected into a turn the agent never took. + const server = this.mcplServerRegistry?.getServer(incoming.channel.serverId); if (!server) { console.error( - `[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`, + `[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channel.channelId)} on server ${logValue(incoming.channel.serverId)} (agent=${agentName})`, ); return; } @@ -12057,7 +12091,7 @@ export class AgentFramework { // the pin. // The last injection that qualifies wins (repinChannel). const routing = this.routingOf(agent.name); - let newLocus: string | undefined; + let newLocus: ChannelRef | undefined; for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus; if (newLocus) { // What this boundary just stored is newer than any arrival the @@ -12523,14 +12557,15 @@ export class AgentFramework { const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content)); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. - if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { - this.conversationRouter.touch(event.channelId); + const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; + if (this.conversationRouter.getBinding(channel)?.agentName === target.name) { + this.conversationRouter.touch(channel); } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed: isAddressedMessage(event.tags, event.metadata), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), @@ -12610,7 +12645,7 @@ export class AgentFramework { timestamp: Date.now(), // Route this turn's auto-published speech back to THIS channel, not // the global most-recent-inbound locus (item-3 redux, trunk agents). - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, // Addressed messages outrank ambient chatter when a batched wake // picks the turn's frozen speech locus. @@ -12643,7 +12678,8 @@ export class AgentFramework { messageMetadata: Record, ): Promise { const router = this.conversationRouter!; - const descriptor = this.channelRegistry?.getDescriptor(event.channelId); + const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; + const descriptor = this.channelRegistry?.getDescriptor(channel); const decision = await this.routeConversation({ channelId: event.channelId, @@ -12656,6 +12692,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-unrouted', channelId: event.channelId, + serverId: event.serverId, messageId: event.messageId, }); return; @@ -12668,6 +12705,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-spawned', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, generation: decision.generation, template: router.templateAgent, @@ -12678,6 +12716,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-spawn-failed', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, error: err.message, }); @@ -12688,10 +12727,11 @@ export class AgentFramework { if (!agent) { // Bound agent vanished (e.g. external reset) — unbind so the next // qualifying message respawns a fresh fork. - router.unbind(event.channelId); + router.unbind(channel); this.emitTrace({ type: 'mcpl:conversation-binding-orphaned', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, }); return; @@ -12708,7 +12748,7 @@ export class AgentFramework { this.storeDueClock(agent, 'event'); } const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content)); - router.touch(event.channelId); + router.touch(channel); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { @@ -12719,7 +12759,7 @@ export class AgentFramework { timestamp: Date.now(), // A fork's home channel wins in routeSpeech regardless, but carry the // triggering channel too so the trunk/active path stays consistent. - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed: isAddressedMessage(event.tags, event.metadata), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), @@ -12736,7 +12776,7 @@ export class AgentFramework { * wholesale-copy pattern; see the seeding comment below), registered in the * event loop like any agent — but never primary. */ - private async createConversationAgent(name: string, channelId: string): Promise { + private async createConversationAgent(name: string, home: ChannelRef): Promise { const router = this.conversationRouter!; const templateConfig = this.agentConfigs.get(router.templateAgent); const template = this.agents.get(router.templateAgent); @@ -12838,7 +12878,7 @@ export class AgentFramework { agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.agents.set(name, agent); this.agentConfigs.set(name, config); - this.conversationAgentHomes.set(name, channelId); + this.conversationAgentHomes.set(name, home); return agent; } catch (error) { this.usedEphemeralAgentNames.delete(name); @@ -12867,15 +12907,16 @@ export class AgentFramework { private async routeConversation(facts: IncomingMessageFacts): Promise< Exclude | (Extract & { spawned: Promise }) > { + const channel: ChannelRef = { serverId: facts.serverId ?? '', channelId: facts.channelId }; for (;;) { - const inFlight = this.conversationSpawns.get(facts.channelId); + const inFlight = this.conversationSpawns.get(destinationKey(channel)); if (inFlight) { await inFlight; continue; } const decision = this.conversationRouter!.route(facts); if (decision.kind !== 'spawn') return decision; - return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) }; + return { ...decision, spawned: this.spawnConversationAgent(channel, decision) }; } } @@ -12900,19 +12941,21 @@ export class AgentFramework { * channel first moves to the spawn's generation, which the store already * holds, and the channel's next qualifying message spawns the next one. */ - private spawnConversationAgent(channelId: string, decision: { agentName: string; generation: number }): Promise { + private spawnConversationAgent(channel: ChannelRef, decision: { agentName: string; generation: number }): Promise { + const key = destinationKey(channel); const spawn = (async () => { - this.storeConversationGeneration(channelId, decision.generation); - const agent = await this.createConversationAgent(decision.agentName, channelId); + // The router's counters are kept by bare id (ConversationRouter.generations). + this.storeConversationGeneration(channel.channelId, decision.generation); + const agent = await this.createConversationAgent(decision.agentName, channel); if (this.agents.get(decision.agentName) !== agent) { - this.conversationRouter!.hydrateGenerations({ [channelId]: decision.generation }); + this.conversationRouter!.hydrateGenerations({ [channel.channelId]: decision.generation }); throw new Error(`conversation fork "${decision.agentName}" ended before its channel was bound`); } - this.conversationRouter!.bind(channelId, decision.agentName, decision.generation); + this.conversationRouter!.bind(channel, decision.agentName, decision.generation); return agent; })(); - const done = (): void => { this.conversationSpawns.delete(channelId); }; - this.conversationSpawns.set(channelId, spawn.then(done, done)); + const done = (): void => { this.conversationSpawns.delete(key); }; + this.conversationSpawns.set(key, spawn.then(done, done)); return spawn; } @@ -12976,7 +13019,7 @@ export class AgentFramework { */ private disposeConversationAgent(agentName: string): void { this.closingConversationAgents.delete(agentName); - const channelId = this.conversationAgentHomes.get(agentName); + const home = this.conversationAgentHomes.get(agentName); const agent = this.agents.get(agentName); // A fork stop() ends can still be running: its stream, its tool round. // Its name is never used again, so its end takes the stream's @@ -13026,7 +13069,8 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-disposed', agentName, - channelId, + channelId: home?.channelId, + serverId: home?.serverId, }); } @@ -13048,10 +13092,14 @@ export class AgentFramework { if (!home || !registry || injections.length === 0) { return injections; } + // A channel's context injection is namespaced by its id alone, so a + // namespace is another channel's where it's a registered channel's id, + // on any server; a server-qualified name isn't a namespace's form. + const ids = new Set(registry.listChannelsRaw().map((e) => e.descriptor.id)); return injections.filter((inj) => { const ns = inj.namespace; - if (!ns || ns === home) return true; - return registry.getDescriptor(ns) === undefined; + if (!ns || ns === home.channelId) return true; + return !ids.has(ns); }); } @@ -13069,10 +13117,11 @@ export class AgentFramework { this.lastConversationSweep = now; for (const binding of this.conversationRouter.expired(now)) { - this.conversationRouter.unbind(binding.channelId); + this.conversationRouter.unbind(binding); this.emitTrace({ type: 'mcpl:conversation-closed', channelId: binding.channelId, + serverId: binding.serverId, agentName: binding.agentName, reason: 'idle-ttl', }); @@ -13176,8 +13225,9 @@ export class AgentFramework { }): { block: ContentBlock; metadataPatch: Record } | null { if (!this.channelRegistry) return null; if (!opts.tags?.includes('chat:addressed')) return null; - if (this.channelRegistry.isChannelOpen(opts.channelId)) return null; - const descriptor = this.channelRegistry.getDescriptor(opts.channelId); + const channel: ChannelRef = { serverId: opts.serverId, channelId: opts.channelId }; + if (this.channelRegistry.isChannelOpen(channel)) return null; + const descriptor = this.channelRegistry.getDescriptor(channel); const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0; const label = opts.channelLabel ?? descriptor?.label; // A label that is only the channel's id (a lazily registered channel's) @@ -13193,11 +13243,11 @@ export class AgentFramework { // target "#DM:"). A registry that can name targets is authoritative: no safe // token means no prefix option. A stand-in without proseTargetFor gets a // whitespace-free guess, never a label with spaces. - const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined }; + const namer = this.channelRegistry as unknown as { proseTargetFor?: (ref: ChannelRef) => string | undefined }; const target = typeof namer.proseTargetFor === 'function' - ? namer.proseTargetFor(opts.channelId, opts.serverId) - : [hashLabel, opts.channelId].find((t): t is string => !!t && !/\s/.test(t)); - const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(opts.channelId, opts.serverId); + ? namer.proseTargetFor(channel) + : [hashLabel, opts.channelId].find((t): t is string => !!t && isProseTargetToken(t)); + const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(channel); 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` @@ -13773,7 +13823,9 @@ export class AgentFramework { // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane. if (!server.grant.has('channels.incoming')) return false; if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false; - return !!this.channelRegistry?.getDescriptor(occ.scope.id); + // This server's own registration: another server's channel with the + // same id carries none of this server's authority. + return !!this.channelRegistry?.getDescriptor({ serverId: occ.serverId, channelId: occ.scope.id }); } return true; } @@ -13784,7 +13836,8 @@ export class AgentFramework { if (explicit) return explicit.filter((n) => this.agents.has(n)); if (occ.scope.kind === 'channel' && this.conversationRouter) { const router = this.conversationRouter; - const bound = router.getBinding(occ.scope.id)?.agentName; + const channel: ChannelRef = { serverId: occ.serverId, channelId: occ.scope.id }; + const bound = router.getBinding(channel)?.agentName; if (bound) return this.agents.has(bound) ? [bound] : []; // No fork yet: the same bind decision an incoming message would get. A // batch that qualifies spawns its fork now so it has someone to wake. @@ -13793,7 +13846,7 @@ export class AgentFramework { channelId: occ.scope.id, serverId: occ.serverId, mentioned: metadata.mentioned === true, - kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata), + kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(channel), metadata), }); if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []); try { @@ -13902,7 +13955,7 @@ export class AgentFramework { reason: 'mcpl:push-event', source: occ.serverId, timestamp: Date.now(), - channelId, + channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, addressed: isAddressedMessage(occ.tags, origin), ...(plain ? (readsDeferredWrite ? { readsDeferredWrite } : {}) : { coalescingSubject: subject, coalescingBatch: true }), @@ -14148,7 +14201,7 @@ export class AgentFramework { reason: 'mcpl:push-event', source: event.serverId, timestamp: Date.now(), - channelId: triggerChannel?.channelId, + channelId: triggerChannel?.channelId, ...(triggerChannel ? { channelServerId: event.serverId } : {}), // DMs and addressed-while-closed messages arrive as push events; // they must outrank ambient chatter in a batched wake's locus // selection just like their channels/incoming counterparts. @@ -14175,15 +14228,55 @@ export class AgentFramework { * included. A fork speaks only in its home channel, and a channel bound to * a fork is that fork's conversation, so no other agent takes it. */ - private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean { + private mayTakeGateRoute(agentName: string, route: ChannelRef): boolean { const home = this.conversationAgentHomes.get(agentName); - if (home !== undefined) return home === routeChannelId; + if (home !== undefined) return sameChannel(home, route); for (const boundChannel of this.conversationAgentHomes.values()) { - if (boundChannel === routeChannelId) return false; + if (sameChannel(boundChannel, route)) return false; } return true; } + /** + * The one channel a wake's channel names: `channelId` on `serverId` when + * the wake gives its server, otherwise the one registered channel that id, + * or its server-qualified name, means (ChannelRegistry.channelsNamed). + * Undefined when it means none or several: channel ids are unique only + * within one server's connection (MCPL §14.2), and a name two servers' + * channels answer to is never resolved to either. + */ + private channelNamed(channelId: string, serverId?: string): ChannelRef | undefined { + if (serverId) return { serverId, channelId }; + const named = this.channelRegistry?.channelsNamed(channelId) ?? []; + return named.length === 1 ? named[0] : undefined; + } + + /** + * Explicit mode's typing channel for one start of a turn, which has no + * locus, read once as the start opens (driveStream then shows what the + * start showed, TurnRouting.typing): where that start's wake came from, + * its trigger's channel with its server (channelNamed), so a start batched + * with a new wake shows typing where that wake came from. Not the turn's + * trigger channel, which a channel_open moves as the turn's default + * outgoing channel. A start whose wake names no one channel, as a budget + * restart's or a re-inference's names none, keeps the channel the turn + * shows typing in (TurnRouting.typing), showing it again where a stop or + * a failed start ended the indicator, since the turn is still at work + * there; a turn that has shown none, as a heartbeat's, shows none. A guard + * retry's wake is its start's, read again. In a turn that has no channel + * (TurnRouting.noChannel), a wake named without its server isn't read: a + * retry's trigger names it again, and reading it then could take a + * channel the turn never had. A wake that gives its server, as every one + * the framework makes for a channel does, names that pair. + */ + private explicitTypingChannel(routing: TurnRouting, trigger: InferenceRequest | undefined): ChannelRef | null { + // A server is read as channelNamed reads one: an empty one as none given. + const own = trigger?.channelId && !(routing.noChannel && !trigger.channelServerId) + ? this.channelNamed(trigger.channelId, trigger.channelServerId) + : undefined; + return own ?? routing.typing ?? null; + } + /** * Derive the MCPL composite channel id (the outbound routing locus) for a * push event from its server-defined `origin`, if it carries one. @@ -14531,7 +14624,10 @@ export class AgentFramework { ? trigger?.readsDeferredWrite : undefined, suppressProse: silentOnly ? trigger?.suppressProse : undefined, silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined, + // The channel and its server from the one request, so the turn's + // channel is that message's, never one server's id with another's. channelId: channelReq?.channelId, + channelServerId: channelReq?.channelServerId, addressed: addressedReq !== undefined, // A context-budget restart continues the same logical turn: it keeps // the channel for routing but names no author — the restart is its @@ -14651,9 +14747,9 @@ export class AgentFramework { } else if (parsed.kind === 'target') { // As deliverHybridProse resolves it: a channel its server declared // inbound-only is refused as a target for speech. - const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!, { forSpeech: true }); + const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!, { forSpeech: true, own: this.ownChannels(routing) }); if (resolved && !('error' in resolved)) { - routing.pin = resolved.channelId; + routing.pin = { serverId: resolved.serverId, channelId: resolved.channelId }; delete routing.hybridHold; } else { delete routing.pin; @@ -14800,9 +14896,11 @@ export class AgentFramework { return JSON.stringify(flat.length > max ? `${flat.slice(0, max)}…` : flat); } - private static destinationText(d: PublishDestination): string { + /** A destination as the agent is shown it: its label, with its name as + * ChannelRegistry.nameShown gives it. */ + private destinationText(d: PublishDestination): string { const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : ''; - return `${label}(${d.channelId})`; + return `${label}(${this.channelRegistry?.nameShown(d) ?? d.channelId})`; } /** One line per draft: id, state, when, why, size, preview. */ @@ -14819,7 +14917,7 @@ export class AgentFramework { if (state === 'unconfirmed') { const risky = uncertainAttempt(draft); if (risky) { - return `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + return `UNCONFIRMED — an attempt to ${this.destinationText(risky.destination)} at ` + `${this.draftTime(risky.at)} may have been posted`; } return `UNCONFIRMED — it includes the words of ${draft.inheritedRisk!.draftId}, which may already have been posted`; @@ -14834,14 +14932,14 @@ export class AgentFramework { private riskText(draft: Draft): string { const risky = uncertainAttempt(draft); if (risky) { - return `${draft.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + return `${draft.id}'s attempt to ${this.destinationText(risky.destination)} at ` + `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`; } const inherited = draft.inheritedRisk!; const owner = inherited.sourceDraftId ?? inherited.draftId; const via = owner !== inherited.draftId ? `, which itself includes the words of ${owner}` : ''; const where = inherited.destination - ? `${owner}'s attempt to ${AgentFramework.destinationText(inherited.destination)}` + + ? `${owner}'s attempt to ${this.destinationText(inherited.destination)}` + (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '') : owner; return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`; @@ -14864,7 +14962,7 @@ export class AgentFramework { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); if (!delivered) return `${draft.id}: not delivered`; return `${draft.id}: delivered — confirmed at ${this.draftTime(delivered.outcome!.at)} to ` + - `${AgentFramework.destinationText(delivered.destination)}` + + `${this.destinationText(delivered.destination)}` + (delivered.outcome!.messageId ? `, message ${delivered.outcome!.messageId}` : '') + ' (a record of that delivery, not a check that the message is still there)'; } @@ -15004,8 +15102,8 @@ export class AgentFramework { /** The drafts tool's answer to a resident's call, from the model's own * dispatch or an agent-origin call through executeToolCall (a puppet's): * a failure the handler throws is answered as one. */ - private draftsToolResult(agentName: string, rawInput: unknown): Promise { - return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({ + private draftsToolResult(agentName: string, rawInput: unknown, turn?: TurnRouting): Promise { + return this.handleDraftsTool(agentName, rawInput, turn).catch((err): ToolResult => ({ success: false, error: `drafts failed: ${thrownText(err)}`, isError: true, @@ -15017,12 +15115,17 @@ export class AgentFramework { * drafts. Private to the calling resident. `resend` is an explicit send: * each draft is published verbatim, in order, through the normal publish * executor, its attempt journaled durably before the request leaves. + * `turn` is the turn the call was made in (callingTurnOf), none for a + * call made outside the agent's current turn: a resend's destination names + * its own channels (ownChannels) by their ids, and its sends engage it. */ - private async handleDraftsTool(agentName: string, rawInput: unknown): Promise { + private async handleDraftsTool(agentName: string, rawInput: unknown, turn?: TurnRouting): Promise { const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record; + const own = turn ? this.ownChannels(turn) : []; // The turn that sends: a resent draft's channel is recorded as engaged on - // it, as a send tool's is, even when the result comes after the turn. - const sendingTurn = this.turnRouting.get(agentName); + // it, as a send tool's is, even when the result comes after the turn. A + // call made outside the turn engages none. + const sendingTurn = turn; const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); // Plain text, not a JSON-quoted string: draft text must read exactly as held. const ok = (text: string): ToolResult => ({ success: true, data: [{ type: 'text', text }] }); @@ -15101,7 +15204,7 @@ export class AgentFramework { : o.status === 'unknown' ? `UNKNOWN — may have been posted: ${o.reason ?? 'no valid receipt'}` : `failed, nothing posted: ${o.reason ?? 'refused'}`; - return ` attempt ${this.draftTime(a.at)} → ${AgentFramework.destinationText(a.destination)}: ${status}` + + return ` attempt ${this.draftTime(a.at)} → ${this.destinationText(a.destination)}: ${status}` + (o?.status === 'unknown' && o.detail !== undefined ? `; connector detail: ${connectorText(o.detail)}` : '') + (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : ''); }), @@ -15173,23 +15276,40 @@ export class AgentFramework { if (present(input.destination) && typeof input.destination !== 'string') { return refuse('destination must be "#channel", "@person" or a channel id.'); } - if (!spec && home) spec = home; - if (!spec) return refuse('resend needs a destination: "#channel", "@person" or a channel id.'); + if (!spec && !home) return refuse('resend needs a destination: "#channel", "@person" or a channel id.'); let confirmDuplicate = false; if (present(input.confirmDuplicate)) { if (typeof input.confirmDuplicate !== 'boolean') return refuse('confirmDuplicate must be true, false or null.'); confirmDuplicate = input.confirmDuplicate; } - const resolved = registry.resolveProseTarget(spec); - if ('error' in resolved) { - const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : ''; - return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`); + // A destination named in the call resolves as a `>>` target does; with + // none, a conversation fork's resend goes home, on its home's server. A + // fork acts only on its home, so a name its home answers to, its id or + // its qualified name, means its home, as in its channel tools. + let named: ChannelRef; + if (home && (spec === home.channelId || spec === qualifiedChannelName(home))) { + named = home; + } else if (spec) { + // A resend posts, so it resolves as speech does (forSpeech), and inside + // its turn a name the turn's own channel answers to means that channel. + const resolved = registry.resolveProseTarget(spec, { forSpeech: true, own }); + if ('error' in resolved) { + const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : ''; + return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`); + } + named = resolved; + } else { + named = home!; } - const target = registry.resolveDestination({ channelId: resolved.channelId }); - if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`); + const target = registry.resolveDestination({ serverId: named.serverId, channelId: named.channelId }); + // Channels named as the agent names them back (nameShown). + if ('error' in target) return refuse(`Destination "${spec || registry.nameShown(home!)}": ${target.error}. Nothing was sent.`); const destination = target.destination; - if (home && destination.channelId !== home) { - return refuse(`This conversation is bound to channel ${home}; resending to ${destination.channelId} is not allowed. Nothing was sent.`); + if (home && !sameChannel(destination, home)) { + return refuse( + `This conversation is bound to channel ${registry.quotedShown(home)}; resending to ${registry.quotedShown(destination)} ` + + 'is not allowed. Nothing was sent.', + ); } // Refuse up front, before anything is published, rather than half-way. @@ -15266,13 +15386,13 @@ export class AgentFramework { channelId: outcome.destination?.channelId ?? destination.channelId, ...(outcome.messageId ? { messageId: outcome.messageId } : {}), }); - const where = AgentFramework.destinationText(outcome.destination ?? destination); + const where = this.destinationText(outcome.destination ?? destination); // A record that failed after reaching the journal was reconciled into // it, so say what the draft reads as now rather than guess. const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`; if (outcome.status === 'delivered') { lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); - sendingTurn?.engaged.add(destination.channelId); + sendingTurn?.engaged.add(destinationKey(destination)); continue; } stopped = `${d.id} was not confirmed delivered`; @@ -15312,10 +15432,10 @@ export class AgentFramework { /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( routing: TurnRouting, - outcome: { delivered: boolean; channelId: string } | null | undefined, + outcome: { delivered: boolean; serverId: string; channelId: string } | null | undefined, ): void { if (!outcome?.delivered) return; - routing.deliveries.push(outcome.channelId); + routing.deliveries.push({ serverId: outcome.serverId, channelId: outcome.channelId }); } /** @@ -15344,15 +15464,11 @@ export class AgentFramework { routing.suppressed = 0; const seen = new Set(); const shown: string[] = []; - for (const id of list ?? []) { - if (seen.has(id)) continue; - seen.add(id); - const label = this.channelRegistry?.getDescriptor(id)?.label; - shown.push( - label && label !== id - ? `${label.startsWith('#') ? label : `#${label}`} (${id})` - : id, - ); + for (const channel of list ?? []) { + const key = destinationKey(channel); + if (seen.has(key)) continue; + seen.add(key); + shown.push(this.channelShown(channel)); } const notes: string[] = []; // Each of the turn's drafts as it stands now: a mid-turn notice lets the @@ -15404,10 +15520,10 @@ export class AgentFramework { const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; // A hybrid `{{unsent}}` envelope is already listed above, as the plain // speech it was; a resend is not. - if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { + if (attempt.via === 'unsent-token' && seen.has(destinationKey(attempt.destination))) { notes.push(`draft ${d.id} delivered ${how}`); } else { - shown.push(`${AgentFramework.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); + shown.push(`${this.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); } } if (notHeld) { @@ -15465,10 +15581,10 @@ export class AgentFramework { * no destination" is the unremarkable boot default). Never triggers * inference; never locus-eligible itself (system + no channel metadata). */ - private announceLocusIfChanged(agentName: string, locus: string | null): void { + private announceLocusIfChanged(agentName: string, locus: ChannelRef | null): void { const hasBaseline = this.lastAnnouncedLocus.has(agentName); const prev = this.lastAnnouncedLocus.get(agentName) ?? null; - if (hasBaseline ? prev === locus : locus === null) { + if (hasBaseline ? sameChannel(prev, locus) : locus === null) { if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus); return; } @@ -15482,11 +15598,7 @@ export class AgentFramework { '[routing] Your plain speech currently has no channel — it stays in ' + 'your archive. Use an explicit send tool to reach a channel.'; } else { - const label = this.channelRegistry?.getDescriptor(locus)?.label; - const shown = - label && label !== locus - ? `${label.startsWith('#') ? label : `#${label}`} (${locus})` - : locus; + const shown = this.channelShown(locus); const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(locus); text = inboundOnlyServer ? `[routing] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` + @@ -15506,7 +15618,7 @@ export class AgentFramework { this.lastAnnouncedLocus.set(agentName, locus); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error( - `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`, + `[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`, ); } catch (err) { console.error('announceLocusIfChanged: failed to record routing notice:', err); @@ -15523,12 +15635,17 @@ export class AgentFramework { * other message. A tool boundary weighs the messages it injects by it, * and noteArrival the ones no boundary of the turn weighs. */ - private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { + private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): ChannelRef | undefined { if (!isConversationalInjection(metadata)) return undefined; const m = metadata as Record | undefined; if (typeof m?.channelId !== 'string') return undefined; - if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId; - return routing.engaged.has(m.channelId) ? m.channelId : undefined; + // The message's own server, which every channel message's metadata + // carries; one without it names its channel only if no other server + // registers that id (channelNamed). + const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined); + if (!channel) return undefined; + if (isAddressedMessage(m.tags as string[] | undefined, m)) return channel; + return routing.engaged.has(destinationKey(channel)) ? channel : undefined; } /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */ @@ -15638,8 +15755,8 @@ export class AgentFramework { const open = this.openLogical.get(agentName); if (!open) return; if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return; - const channelId = this.repinChannel(metadata, open); - if (channelId) open.arrivals.push({ messageId, channelId }); + const channel = this.repinChannel(metadata, open); + if (channel) open.arrivals.push({ messageId, channel }); } /** @@ -15668,14 +15785,14 @@ export class AgentFramework { } /** - * Show the turn's typing indicator in `channelId`, stopping the one it last + * Show the turn's typing indicator in `channel`, stopping the one it last * showed elsewhere: this stream's, or one an earlier stream of the turn * left running when it handed the turn on. */ - private showTurnTyping(routing: TurnRouting, channelId: string): void { - if (routing.typing !== undefined && routing.typing !== channelId) this.channelRegistry?.stopTyping(routing.typing); - this.channelRegistry?.startTyping(channelId); - routing.typing = channelId; + private showTurnTyping(routing: TurnRouting, channel: ChannelRef): void { + if (routing.typing !== undefined && !sameChannel(routing.typing, channel)) this.channelRegistry?.stopTyping(routing.typing); + this.channelRegistry?.startTyping(channel); + routing.typing = channel; } /** @@ -15686,14 +15803,14 @@ export class AgentFramework { * message in the store; the last compile can't be the test, since an * arrival between the turn's streams isn't in it yet. */ - private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined { + private remainingArrival(agent: Agent, routing: TurnRouting): ChannelRef | undefined { const cm = agent.getContextManager(); for (let i = routing.arrivals.length - 1; i >= 0; i--) { - const { messageId, channelId } = routing.arrivals[i]!; + const { messageId, channel } = routing.arrivals[i]!; // The store answers null for a message that isn't there. A store that // can't be read throws here, as anywhere else in the start, rather // than have its candidates read as withdrawn. - if (cm.getMessage(messageId)) return channelId; + if (cm.getMessage(messageId)) return channel; } return undefined; } @@ -15713,17 +15830,15 @@ export class AgentFramework { private repinTurnLocus( agent: Agent, routing: TurnRouting, - channelId: string, + channel: ChannelRef, cause: string, ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined { - if (routing.locus === channelId) return undefined; + if (sameChannel(routing.locus, channel)) return undefined; const prevPin = routing.locus ?? null; - routing.locus = channelId; - const label = this.channelRegistry?.getDescriptor(channelId)?.label; - const shown = label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId; - const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(channelId); + routing.locus = channel; + noteTurnChannel(routing, channel); + const shown = this.channelShown(channel); + const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(channel); const content: ContentBlock[] = [{ type: 'text', text: inboundOnlyServer @@ -15736,9 +15851,9 @@ export class AgentFramework { const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; try { const id = agent.getContextManager().addMessage('user', content, metadata); - this.lastAnnouncedLocus.set(agent.name, channelId); + this.lastAnnouncedLocus.set(agent.name, channel); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); - console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`); + console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`); return { participant: 'user', content, metadata }; } catch (err) { console.error('turn locus re-pin: failed to record routing notice:', err); @@ -15825,8 +15940,8 @@ export class AgentFramework { channels: Array<{ channelId: string; label?: string }>, cause: ChannelOpenedCause, ): ChannelNotice[] { - const shown = channels.map(shownChannel).join(', '); - const ids = channels.map((c) => c.channelId); + const shown = channels.map((c) => this.labelledChannel({ serverId, ...c })).join(', '); + const refs = channels.map((c) => ({ serverId, channelId: c.channelId })); const plural = channels.length > 1; // What the channels deliver is said only to an agent that holds their // traffic (holdsChannelTraffic), and channel_close is named only to one @@ -15853,7 +15968,7 @@ export class AgentFramework { const sender = this.agentForPrimary(agentName); return [{ text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` + - optOut(this.holdsChannelTraffic(primary, ids), this.canCloseChannels(primary, ids)), + optOut(this.holdsChannelTraffic(primary, refs), this.canCloseChannels(primary, refs)), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} has ended)`, shown, @@ -15862,7 +15977,7 @@ export class AgentFramework { // The reader: the agent whose send opened the channels, or for a policy // admission, the agent addMessage delivers to (deliveryAgent). const who = agentName || this.deliveryAgent()?.name; - const canClose = this.canCloseChannels(who, ids); + const canClose = this.canCloseChannels(who, refs); // Where the agent whose send opened them can't close them, as a fork away // from its home can't, or an agent whose allowedTools leave channel_close // out, the primary is told by name too, where it's registered and can @@ -15870,7 +15985,7 @@ export class AgentFramework { // and the agent hears who was told. (The primary can't be that agent: it // can close them, and that agent can't.) const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, ids); + this.canCloseChannels(primary, refs); const readerText = (told: string) => (cause === 'subscription-policy' ? `[channels] Selected for opening by subscription policy: ${shown}. ` + (plural @@ -15879,7 +15994,7 @@ export class AgentFramework { : 'The host opens it, and if that fails, tries again when its server next registers it; ' + "channel_list shows whether it's open yet.") : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + - optOut(this.holdsChannelTraffic(who, ids), canClose) + told; + optOut(this.holdsChannelTraffic(who, refs), canClose) + told; const reader: ChannelNotice = { text: readerText(tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''), ...(tellPrimary ? { untold: readerText('') } : {}), @@ -15896,7 +16011,7 @@ export class AgentFramework { const sender = this.agentForPrimary(agentName); notices.push({ text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it, and ${sender.again} can't close ${plural ? 'them' : 'it'}.` + - optOut(this.holdsChannelTraffic(primary, ids), true), + optOut(this.holdsChannelTraffic(primary, refs), true), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`, shown, @@ -15907,19 +16022,19 @@ export class AgentFramework { } /** - * Whether `who` can call channel_close for every one of `channelIds` here: + * Whether `who` can call channel_close for every one of `channels` here: * its allowedTools hold it (canAgentUseTool), and a conversation fork, * whose channel_close dispatchChannelToolCall refuses for any channel but * its home, only for its home. */ - private canCloseChannels(who: string | undefined, channelIds: readonly string[]): boolean { + private canCloseChannels(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; const home = this.conversationAgentHomes.get(who); - return channelIds.every((id) => !home || home === id) && this.canAgentUseTool(who, 'channel_close'); + return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close'); } /** - * Whether `who` holds the traffic of every one of `channelIds`, so that + * Whether `who` holds the traffic of every one of `channels`, so that * what an open channel delivers is in its window. Where conversations are * routed, an incoming message goes to the fork its channel is bound to, or * nowhere, and never to the primary (handleMcplChannelIncoming), so a fork @@ -15935,11 +16050,11 @@ export class AgentFramework { * comes whether or not the channel is open, so it isn't what opening one * delivers.) */ - private holdsChannelTraffic(who: string | undefined, channelIds: readonly string[]): boolean { + private holdsChannelTraffic(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; const home = this.conversationAgentHomes.get(who); if (home !== undefined) { - return channelIds.every((id) => id === home && this.conversationRouter?.getBinding(id)?.agentName === who); + return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who); } if (this.conversationRouter) return false; return this.residentHoldsTraffic(who); @@ -15980,7 +16095,7 @@ export class AgentFramework { const home = this.conversationAgentHomes.get(agentName); if (home === undefined) return { first: agentName, again: agentName }; const label = this.channelRegistry?.getDescriptor(home)?.label; - const bound = label && label !== home ? `${label} (${home})` : home; + const bound = this.labelledChannel({ ...home, ...(label ? { label } : {}) }); const fork = this.forkOfPrimary(agentName) ? 'your fork' : 'the fork'; return { first: `${fork} in ${bound}, named ${agentName},`, again: 'that fork' }; } @@ -16059,7 +16174,7 @@ export class AgentFramework { channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): ChannelNotice[] { - const shown = shownChannel(channel); + const shown = this.labelledChannel({ serverId, ...channel }); const ended = !!agentName && !this.agents.has(agentName); if (ended && !selectedForOpening) { console.error(`[channel] ${agentName} has ended; not telling it that ${shown} couldn't be opened`); @@ -16072,7 +16187,7 @@ export class AgentFramework { } const who = ended ? primary : agentName || this.deliveryAgent()?.name; const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open'); - const canClose = this.canCloseChannels(who, [channel.channelId]); + const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]); const selected = selectedForOpening === 'unrecorded' ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again" : selectedForOpening !== undefined @@ -16087,7 +16202,7 @@ export class AgentFramework { // can't be the reader: it can close it, and the reader can't.) const tellPrimary = !ended && selectedForOpening !== undefined && who !== undefined && !canClose && primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, [channel.channelId]); + this.canCloseChannels(primary, [{ serverId, channelId: channel.channelId }]); const sender = ended ? this.agentForPrimary(agentName!) : undefined; const readerText = (told: string) => `[channels] Not open: ${shown}. ` + (ended @@ -16169,6 +16284,56 @@ export class AgentFramework { return routing; } + /** + * A turn's own channels, every channel that has been its trigger or its + * locus (TurnRouting.wasIn), so a channel_open or a re-pin adds one and + * drops none: a `>>` target, a drafts resend's destination or a channel + * tool's name one of them answers to by its id means that channel, on its + * server (ChannelRegistry.ownChannelNamed). A tool call takes them only + * when it is made inside that turn (callOwnChannels). + */ + private ownChannels(routing: TurnRouting): ChannelRef[] { + const own = [...routing.wasIn]; + for (const c of [routing.triggerChannel, routing.locus]) { + if (c && !own.some((o) => sameChannel(o, c))) own.push(c); + } + return own; + } + + /** + * The agent's current turn, where a tool call was made inside it, else + * none: a call made outside it, such as a script's left running after its + * turn, carries no turn (ToolCall.callerTurnId), and a script's call in a + * turn other than the one that started the script isn't that turn's. The + * one test for both what a call's names mean (callOwnChannels) and which + * turn's routing it may change: a channel_open's move of the trigger and + * locus, and the engagement of a send or a drafts resend. Read while the + * call is dispatched, since a script's start turn is kept only then. + */ + private callingTurnOf(agentName: string, call: ToolCall): TurnRouting | undefined { + // The agent's latest routing, or its open turn's where a silent start's + // routing is the latest: a call the open turn makes meanwhile, as a + // script it watches does, is still that turn's. + const latest = this.turnRouting.get(agentName); + const open = this.openLogical.get(agentName); + const routing = call.callerTurnId === undefined ? undefined + : latest?.turnId === call.callerTurnId ? latest + : open?.turnId === call.callerTurnId ? open : undefined; + if (!routing) return undefined; + // A script's call carries the turn watching the script, which may be a + // later turn than the one its words were written in: it's that turn's + // only where that turn is the one that started the script. + if (this.scriptCallStartTurns.has(call.id) && this.scriptCallStartTurns.get(call.id) !== routing.turnId) return undefined; + return routing; + } + + /** The own channels (ownChannels) of the turn a tool call was made in + * (callingTurnOf), and none for a call made outside it. */ + private callOwnChannels(agentName: string, call: ToolCall): ChannelRef[] { + const turn = this.callingTurnOf(agentName, call); + return turn ? this.ownChannels(turn) : []; + } + /** * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md), * entered directly: parse the `>>` prefix, resolve the target, deliver, or @@ -16185,8 +16350,9 @@ export class AgentFramework { ): Promise { // The direct entry delivers as a live round does: under the live turn's // routing. - await this.deliverAdmittedProse(agent, this.admitProse(rawText), { - routing: this.routingOf(agent.name), + const routing = this.routingOf(agent.name); + await this.deliverAdmittedProse(agent, this.admitProse(rawText, routing), { + routing, notice: round.notice ?? 'later', keepPrivate: round.keepPrivate, }); @@ -16198,12 +16364,13 @@ export class AgentFramework { * delivery posts to these resolutions, however the registry's names change * before it runs. */ - private admitProse(rawText: string): AdmittedEnvelope[] { + private admitProse(rawText: string, routing: TurnRouting): AdmittedEnvelope[] { + const own = this.ownChannels(routing); return splitExplicitEnvelopes(rawText).map((text) => { const parsed = parseProsePrefix(text); if (parsed.kind !== 'target') return { text }; try { - return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }) }; + return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true, own }) }; } catch (err) { // Admission runs in the stream's event loop, so a resolver that // throws makes the envelope bounce rather than stop the round. @@ -16326,13 +16493,13 @@ export class AgentFramework { private async deliverWithUnsent( agent: Agent, body: string, - channelId: string, + channel: ChannelRef, unsent: Draft | undefined, ): Promise { const registry = this.channelRegistry!; let attemptId: string | undefined; if (unsent) { - const target = registry.resolveDestination({ channelId }); + const target = registry.resolveDestination({ serverId: channel.serverId, channelId: channel.channelId }); if (!('error' in target)) { try { attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false); @@ -16353,7 +16520,7 @@ export class AgentFramework { try { // Both callers hold a failed delivery's words as a bounce draft, whose // notice says what happened, so the registry writes no second marker. - outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true }); + outcome = await registry.deliverSpeech(agent.name, body, channel, { heldOnFailure: true }); } finally { if (flight) { this.draftsInFlight.delete(flight); @@ -16395,7 +16562,7 @@ export class AgentFramework { private async deliverHybridProse( agent: Agent, rawText: string, - locus: string | null, + locus: ChannelRef | null, allowLocus: boolean, round: ProseRound, ): Promise { @@ -16414,7 +16581,7 @@ export class AgentFramework { continue; } if (parsed.kind === 'target') { - const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }); + const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true, own: this.ownChannels(routing) }); if ('error' in resolved) { delete routing.pin; routing.hybridHold = 'failed'; @@ -16431,15 +16598,16 @@ export class AgentFramework { } // A function replacement: a draft's words go in exactly, `$&` and `$$` included. if (usedClipboard) body = body.replaceAll('{{unsent}}', () => unsent.draft?.text ?? ''); - routing.pin = resolved.channelId; + const pinned: ChannelRef = { serverId: resolved.serverId, channelId: resolved.channelId }; + routing.pin = pinned; delete routing.hybridHold; if (!body.trim()) { console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`); continue; } try { - const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); - this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null); + const outcome = await this.deliverWithUnsent(agent, body, pinned, unsent.draft); + this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); if (outcome.status === 'delivered') { this.proseBounceStreaks.delete(agent.name); round.onPublished?.(outcome.destination!, body, outcome.messageId); @@ -16450,13 +16618,13 @@ export class AgentFramework { // not bounced, so its words are not re-held as a fresh draft that // invites a duplicate. if (outcome.status === 'failed') { - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft }); + this.bounceProse(agent, parsed.body, `delivery to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft }); } } } catch (err) { delete routing.pin; routing.hybridHold = 'failed'; - this.bounceProse(agent, parsed.body, `sending to ${resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' }); + this.bounceProse(agent, parsed.body, `sending to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } continue; } @@ -16566,7 +16734,7 @@ export class AgentFramework { return; } - let targetChannel: string; + let targetChannel: ChannelRef; let body: string; if (parsed.kind === 'target') { // Resolved once, when the round was admitted (admitProse), never again @@ -16581,7 +16749,7 @@ export class AgentFramework { this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', round); return; } - targetChannel = res.channelId; + targetChannel = { serverId: res.serverId, channelId: res.channelId }; // Sticky within the turn: later unprefixed segments follow this target. routing.pin = targetChannel; body = parsed.body; @@ -16618,10 +16786,10 @@ export class AgentFramework { } else if (outcome.status === 'failed') { // Unknown deliveries are marker-only: offering a fresh draft would // invite a duplicate. Keep the authored body for the re-bounce rule. - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft }); + this.bounceProse(agent, envelopeBody, `delivery to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft }); } } catch (err) { - this.bounceProse(agent, envelopeBody, `sending to ${targetChannel} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' }); + this.bounceProse(agent, envelopeBody, `sending to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } if (!delivered && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) { // The channel is inbound-only by now: its server declared it after the @@ -16630,7 +16798,7 @@ export class AgentFramework { // text there would be refused, so the pin ends, as a refusal at // admission ends it, unless a later delivery has moved it. A failed // delivery to a channel that isn't inbound-only keeps it, as before. - if (routing.pin === targetChannel) delete routing.pin; + if (sameChannel(routing.pin, targetChannel)) delete routing.pin; } } @@ -16751,6 +16919,10 @@ export class AgentFramework { const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; this.proseBounceStreaks.set(name, streak); const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : ''; + // The examples name the first candidate that is one token, as a target + // takes it, so they show the form to write, such as a server-qualified + // `a:general`; with none, a generic `#channel`. + const example = candidates?.find(isProseTargetToken) ?? '#channel'; // A bare {{unsent}} whose send failed re-bounces the draft it sent, which // a later bounce may have displaced as the latest; and {{unsent}} refuses // a draft that may already have been posted. Then only the drafts tool @@ -16761,11 +16933,11 @@ export class AgentFramework { ? 'It could not be held as a draft; the words remain in your history.' : byToken ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` + - `token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + - `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + + `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"). ` + `"${prefix}skip_reply {{unsent}}" sets it aside.` : `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, use ` + - `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"` + `${inherited || ownRisk ? ', confirmDuplicate: true' : ''}); ` + `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`; const risky = inherited && draft @@ -17359,10 +17531,23 @@ export class AgentFramework { if (opensTurn) { // The turn's trigger channel (item-3 redux): the channel of a - // channel/DM-triggered turn, and none for a heartbeat / no-trigger - // turn, which falls back to the global default rather than inheriting - // a previous turn's channel. - routing.triggerChannel = trigger?.channelId; + // channel/DM-triggered turn, with its server, and none for a heartbeat + // / no-trigger turn, which falls back to the global default rather + // than inheriting a previous turn's channel. A wake whose channel, + // given without its server, names no one registered channel (a host's + // id that two servers' channels answer to, or that none does) has none + // either, and takes no fallback: it was for a channel, and the latest + // inbound one would be a guess. A wake that gives its server names that + // pair exactly, registered or not (channelNamed), as every selector + // with a server does; while it isn't registered, the turn's speech to + // it is refused, with no fallback either. + routing.triggerChannel = trigger?.channelId + ? this.channelNamed(trigger.channelId, trigger.channelServerId) + : undefined; + noteTurnChannel(routing, routing.triggerChannel); + const unresolvedTrigger = !!trigger?.channelId && routing.triggerChannel === undefined; + if (unresolvedTrigger) routing.noChannel = true; + else delete routing.noChannel; // Resolve the turn's outbound locus now: the home channel for forks, // else the trigger channel, else the global default, which an // ephemeral run never takes (takesGlobalFallback). Ambient mid-turn @@ -17379,8 +17564,12 @@ export class AgentFramework { // (announce-on-change only — no per-turn chatter, append-only for KV // stability). if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') { + // None for a turn with no channel (noChannel), unless it's a fork's. const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; - if (locus !== null) routing.locus = locus; + if (locus !== null) { + routing.locus = locus; + noteTurnChannel(routing, locus); + } if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); } } else { @@ -17388,7 +17577,9 @@ export class AgentFramework { // that continues the open turn. A message no tool boundary of the turn // weighed can still move it, as it would have at a boundary // (TurnRouting.arrivals). - // Only a turn that has a locus reads its arrivals' messages. + // A turn in a mode with a locus reads its arrivals' messages, a turn + // with no channel (noChannel) included: an addressed message, or a + // follow-up where the turn sent, gives it its channel, as a re-pin does. const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid' ? this.remainingArrival(agent, routing) : undefined; @@ -17405,7 +17596,8 @@ export class AgentFramework { type: 'inference:started', agentName: agent.name, // The turn's locus, kept across a start that continues the turn. - channelId: turnLocus, + channelId: turnLocus?.channelId, + ...(turnLocus ? { serverId: turnLocus.serverId } : {}), ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The @@ -17431,9 +17623,11 @@ export class AgentFramework { // Same channel choice as driveStream's own startTyping (which stays, is // idempotent per channel, and owns the 7s refresh); the catch below stops // it on the no-driveStream failure paths (e.g. a compile refusal). + // Explicit mode's is where this start's wake came from + // (explicitTypingChannel). const earlyTypingChannel = turnProseRouting === 'explicit' - ? trigger?.channelId ?? null + ? this.explicitTypingChannel(routing, trigger) : turnLocus ?? null; if (earlyTypingChannel) this.showTurnTyping(routing, earlyTypingChannel); @@ -17472,7 +17666,7 @@ export class AgentFramework { const hookParams = this.buildBeforeInferenceParams(agent, trigger); const hookInjections = this.scopeInjectionsForAgent( agent.name, - await this.hookOrchestrator.beforeInference(hookParams), + await this.hookOrchestrator.beforeInference(hookParams, (serverId) => this.channelsForServer(agent.name, serverId)), ); if (hookInjections.length > 0) { injections = injections ? [...injections, ...hookInjections] : hookInjections; @@ -17921,7 +18115,7 @@ export class AgentFramework { // reactions arriving at a tool boundary must not redirect the agent's // task narration (2026-07-21 Cairn misroute). The locus lives in the // turn's routing state, so every stream of the turn shares it. - const resolveTurnLocus = (): string | null => streamRouting.locus ?? null; + const resolveTurnLocus = (): ChannelRef | null => streamRouting.locus ?? null; // Ordered delivery chain for live-routed prose. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post @@ -18016,7 +18210,7 @@ export class AgentFramework { } }; - const enqueueSpeech = (text: string, locus: string | null): void => { + const enqueueSpeech = (text: string, locus: ChannelRef | null): void => { chainSpeech(async () => { const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); this.recordProseDelivery(streamRouting, outcome); @@ -18073,16 +18267,19 @@ export class AgentFramework { // can move it between rounds, and followTurnLocus (below) moves the // indicator with it at the next round's first content, or at its // retry after a refusal. - // - explicit mode: there is no locus — indicate on the TRIGGER channel - // (where the wake came from). This doesn't violate never-guess: it - // says "attending to what you sent here", which is true regardless - // of where the reply goes. Heartbeat/no-trigger explicit turns show - // no indicator. + // - explicit mode: there is no locus — indicate on the TRIGGER channel, + // where this start's wake came from, as the start's opening showed + // it (explicitTypingChannel), not read again after its hooks and + // compile. This doesn't violate never-guess: it says "attending to + // what you sent here", which is true regardless of where the reply + // goes. A start whose wake names no channel keeps what the turn + // shows; a heartbeat explicit turn shows none, and a turn with no + // channel doesn't read a wake that gives no server. let typingChannel = turnProseRouting === 'disabled' ? null : turnProseRouting === 'explicit' - ? trigger?.channelId ?? null + ? streamRouting.typing ?? null : resolveTurnLocus(); if (typingChannel) this.showTurnTyping(streamRouting, typingChannel); // Locus and hybrid mode: move the indicator to the turn's locus when it @@ -18091,7 +18288,7 @@ export class AgentFramework { const followTurnLocus = (): void => { if (turnProseRouting !== 'locus' && turnProseRouting !== 'hybrid') return; const locus = resolveTurnLocus(); - if (!locus || locus === typingChannel) return; + if (!locus || sameChannel(locus, typingChannel)) return; this.showTurnTyping(streamRouting, locus); typingChannel = locus; }; @@ -18249,7 +18446,7 @@ export class AgentFramework { // The channel the typing indicator above shows, the conversation // the turn is attending: lets trace consumers tag every chunk // with it, without re-deriving routing. - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }; if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { guardHeld.push(trace); @@ -18280,7 +18477,7 @@ export class AgentFramework { content: '', blockType: 'text', blockIndex: 0, - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }); break; } @@ -18293,7 +18490,7 @@ export class AgentFramework { phase, blockType: block.type, blockIndex: index, - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }); break; } @@ -18438,7 +18635,7 @@ export class AgentFramework { // An explicit round is admitted as it ends: its targets are // resolved once, for its queued delivery. const roundAdmitted = turnProseRouting === 'explicit' - ? roundSegments.map((seg) => this.admitProse(seg)) + ? roundSegments.map((seg) => this.admitProse(seg, streamRouting)) : []; const routeRound = (calls: readonly RoundCall[]): void => { if (!this.channelRegistry) return; @@ -18841,8 +19038,8 @@ export class AgentFramework { // The final text is delivered under this turn's routing, as its // queued rounds are. const finalRouting = streamRouting; - const admittedSpeechSegments = admitFinal ? finalSpeechSegments.map((seg) => this.admitProse(seg)) : []; - const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : []; + const admittedSpeechSegments = admitFinal ? finalSpeechSegments.map((seg) => this.admitProse(seg, finalRouting)) : []; + const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg, finalRouting)) : []; const du = response.details?.usage; // A refusal's note carries what completeTurn counts. @@ -19064,7 +19261,7 @@ export class AgentFramework { // frozen pin is immune to both races. const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ? qualifiedChannelName(locus) : '(none)'}`, ); for (const segment of speechSegments) { try { @@ -19161,7 +19358,7 @@ export class AgentFramework { // one channel must not land its postscript in another. const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ? qualifiedChannelName(locus) : '(none)'}`, ); // Deliver sequentially (await each) so the segments land in order. for (const seg of segments) { @@ -20150,8 +20347,10 @@ export class AgentFramework { } // drafts: the calling resident's own held drafts. A puppeted call has - // agent origin and acts as that resident, as the model's own call does - // through dispatchToolCall. A module holds no drafts. + // agent origin and acts on that resident's drafts, as the model's own + // call does through dispatchToolCall, but it isn't made inside the + // resident's turn, so it takes none of the turn's channels and engages + // none (callingTurnOf). A module holds no drafts. if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') { return this.draftsToolResult(origin.agentName, call.input); } @@ -20910,7 +21109,7 @@ export class AgentFramework { if (refusal !== undefined) return refused(refusal); const result = await this.dispatchScriptToolCall( agentName, toolName, input as Record, - context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, + context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, context.startTurnId, ); // Deferred: applied to the result of the code_execution call watching // the script — ending the turn mid-script would cancel the stream while @@ -21017,7 +21216,10 @@ export class AgentFramework { * RFC-007: with an `origin`, observers see the call as they see a model * call (register, dispatch with its refusal sites, open; the terminal at * the waiter intercept), under the inner tool's own name and class. - * `turnId` is the turn the call was made in (dispatchToolCall). + * `turnId` is the turn the call was made in (dispatchToolCall), the turn + * of the run or wait call watching the script; `startTurnId` is the turn + * the script was started in, whose words its calls carry + * (scriptCallStartTurns). */ private dispatchScriptToolCall( agentName: string, @@ -21025,6 +21227,7 @@ export class AgentFramework { args: Record, origin?: ScriptCallOrigin, turnId?: number, + startTurnId?: number, ): Promise { return new Promise((resolve) => { const callId = `pytc-${randomUUID()}`; @@ -21059,6 +21262,9 @@ export class AgentFramework { const call = { id: callId, name: toolName, input: args }; if (origin) this.toolLifecycleEmitter?.registerScriptCall(agentName, origin, call); + // The dispatch reads the call's own channels as it goes + // (callOwnChannels), so the start turn is kept only while it runs. + this.scriptCallStartTurns.set(callId, startTurnId); try { this.dispatchToolCall(agentName, call, turnId); } catch (error) { @@ -21077,6 +21283,8 @@ export class AgentFramework { resolve({ success: false, error: err.message, isError: true }); } return; + } finally { + this.scriptCallStartTurns.delete(callId); } if (origin) this.toolLifecycleEmitter?.open(agentName, callId); }); @@ -21665,7 +21873,7 @@ export class AgentFramework { (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name) ) { void this.tuneOutCoordinator - .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record) + .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record, this.callOwnChannels(agentName, enrichedCall)) // A rejection is answered with a failed result, as a framework tool's // dispatch failure is (dispatchUtilsToolCall), never left unhandled. .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true })) @@ -21752,7 +21960,7 @@ export class AgentFramework { if (enrichedCall.name === 'drafts' && this.channelRegistry) { const startedAt = Date.now(); this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input }); - void this.draftsToolResult(agentName, enrichedCall.input) + void this.draftsToolResult(agentName, enrichedCall.input, this.callingTurnOf(agentName, enrichedCall)) .then((result) => { this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt }); this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'framework', result }); @@ -22877,14 +23085,14 @@ export class AgentFramework { return desired === 'open' || desired === 'tuned-out'; }); if (channels.length === 0) { - console.error(`[channel] ${speaker} has ended, and no channel its notice said opened is decided open or tuned out now (${passOn.channels.map(shownChannel).join(', ')}): no one is told`); + console.error(`[channel] ${speaker} has ended, and no channel its notice said opened is decided open or tuned out now (${passOn.channels.map((c) => this.labelledChannel({ serverId, ...c })).join(', ')}): no one is told`); return null; } [notice] = this.channelOpenedNotices(speaker, serverId, channels, passOn.cause); } else { const { channelId } = passOn.channel; - if (desiredState(channelId) !== 'open' || registry?.isChannelOpen(channelId, serverId)) { - console.error(`[channel] ${speaker} has ended, and ${shownChannel(passOn.channel)}, which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told`); + if (desiredState(channelId) !== 'open' || registry?.isChannelOpen({ serverId, channelId })) { + console.error(`[channel] ${speaker} has ended, and ${this.labelledChannel({ serverId, ...passOn.channel })}, which its notice said was not open and selected for opening, is open now or no longer decided open: no one is told`); return null; } [notice] = this.replyOpenFailedNotices(speaker, serverId, passOn.channel, passOn.selectedForOpening); @@ -22899,7 +23107,7 @@ export class AgentFramework { metadata: MessageMetadata | undefined, ): { content: ContentBlock[]; metadata: MessageMetadata } | null { const failure = metadata as { - kind?: unknown; channelId?: unknown; reason?: unknown; textLen?: unknown; outcome?: unknown; detail?: unknown; + kind?: unknown; channelId?: unknown; serverId?: unknown; reason?: unknown; textLen?: unknown; outcome?: unknown; detail?: unknown; } | undefined; if (failure?.kind !== 'discord-send-failed') return null; if (typeof failure.channelId !== 'string' || failure.channelId === '') { @@ -22920,7 +23128,10 @@ export class AgentFramework { const detail = typeof failure.detail === 'string' ? failure.detail : undefined; const size = textLen !== undefined ? ` (${textLen} chars)` : ''; const why = reason !== undefined ? ` (${reason})` : ''; - const where = this.channelForNotice(failure.channelId); + // A notice queued by an earlier build names no server: its channel is + // the one registered channel its id means, or shown by its id alone. + const named = this.channelNamed(failure.channelId, typeof failure.serverId === 'string' ? failure.serverId : undefined); + const where = named ? this.channelForNotice(named) : failure.channelId; const text = outcome === 'unknown' ? `[discord-send-failed] Delivery of a reply by ${speaker}${size} to ${where} was not confirmed${why}. ` + `It may or may not have been posted, so sending it again could post it twice. ${speaker} has ended.` @@ -22930,6 +23141,8 @@ export class AgentFramework { content: [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }], metadata: { system: true, kind: 'discord-send-failed', channelId: failure.channelId, speaker, + // The channel's server, as the marker it passes on carries it. + ...(typeof failure.serverId === 'string' ? { serverId: failure.serverId } : {}), ...(reason !== undefined ? { reason } : {}), ...(textLen !== undefined ? { textLen } : {}), ...(outcome !== undefined ? { outcome } : {}), @@ -22943,14 +23156,31 @@ export class AgentFramework { * a bare snowflake is unresolvable for the agent (the 2026-07-21 incident * read as "a stale artifact", not a live failure). */ - private channelForNotice(channelId: string | null): string { - if (!channelId) return 'the channel'; - const label = this.channelRegistry?.getDescriptor(channelId)?.label; - return label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId; + private channelForNotice(channel: ChannelRef | null | undefined): string { + return channel ? this.channelShown(channel) : 'the channel'; } + /** + * A channel as the agent is shown it: its label, with the name that picks + * it alone (ChannelRegistry.nameShown), which is its id, or, where another + * server registers the same id, its server-qualified name; or, for a + * channel no string picks alone, its id and its server. + */ + private channelShown(channel: ChannelRef): string { + const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId; + const label = this.channelRegistry?.getDescriptor(channel)?.label; + return label && label !== channel.channelId + ? `${label.startsWith('#') ? label : `#${label}`} (${name})` + : name; + } + /** A channel named in a notice with the label it was given there: the + * label, with its name as ChannelRegistry.nameShown gives it. */ + private labelledChannel(channel: ChannelRef & { label?: string }): string { + const name = this.channelRegistry?.nameShown(channel) ?? channel.channelId; + return channel.label && channel.label !== channel.channelId ? `${channel.label} (${name})` : name; + } + + /** * A module's write through its context: addMessage's, except that a write * the framework holds is answered by its handle (heldHandle) rather than @@ -23500,7 +23730,9 @@ export class AgentFramework { category, streak, // The refused agent's own view: a subagent's refusal names no channel. - locus: this.channelRegistry?.buildChannelContext(agentName)?.incoming?.channelId ?? null, + // Named as the agent is shown it, so a shared id names its server. + locus: ((channel) => (channel ? this.channelRegistry?.nameShown(channel) ?? channel.channelId : null))( + this.channelRegistry?.incomingFor(agentName)?.channel), tokens: tokens ?? null, }); if (streak >= 2) { @@ -23961,7 +24193,7 @@ export class AgentFramework { // keeps that channel. activeChannelResolver: (agentName) => { const routing = this.turnRouting.get(agentName); - return routing?.locus ?? routing?.triggerChannel; + return routing?.locus ?? routing?.triggerChannel ?? (routing?.noChannel ? null : undefined); }, // An ephemeral run (a subagent) never takes the process-global // fallback: its prose is its caller's result, and the latest inbound @@ -23988,14 +24220,14 @@ export class AgentFramework { // or wasn't confirmed to. addMessage() alone does not request // inference, so this never wakes her (matching the // `discord-send-failed-skip` gate intent: context yes, wake no). - onRouteFailure: ({ conversationId, channelId, reason, textLen, outcome, detail }) => { + onRouteFailure: ({ conversationId, channelId, serverId, reason, textLen, outcome, detail }) => { try { // The marker is `system: true`, so it is never conversational and // never influences routing. The channel is named by its label when // the registry has one (channelForNotice). - const where = this.channelForNotice(channelId); + const where = this.channelForNotice(channelId !== null && serverId !== undefined ? { serverId, channelId } : null); const metadata: MessageMetadata = { - system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, textLen, + system: true, kind: 'discord-send-failed', channelId: channelId ?? '', ...(serverId !== undefined ? { serverId } : {}), reason, textLen, ...(outcome ? { outcome } : {}), ...(detail !== undefined ? { detail } : {}), }; if (!this.agents.has(conversationId)) { @@ -27109,7 +27341,8 @@ export class AgentFramework { const args = (call.input && typeof call.input === 'object') ? call.input as Record : {}; // The turn that sends: a send's engaged channel is recorded on it, even // when the send's result, or the open that follows, comes after the turn. - const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined; + // A call made outside the turn engages none (callingTurnOf). + const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.callingTurnOf(agentName, call) : undefined; const dispatchRedactor = serverTextRedactor(config ?? {}); const failDispatch = (error: unknown) => { @@ -27274,7 +27507,7 @@ export class AgentFramework { const directs = this.channelRegistry.directsLifecycle(serverId); this.channelRegistry .openIfClosedForSend(target, serverId) - .then(({ status, channelId, label, opening, selectedForOpening }) => { + .then(({ status, channelId, serverId: channelServerId, label, opening, selectedForOpening }) => { // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31 // n=7): a human follow-up in a channel the agent explicitly // sent into this turn counts as conversationally addressed. @@ -27287,7 +27520,7 @@ export class AgentFramework { // is unconfirmed is opened again first, so its engagement is // recorded when that open returns. if (status !== 'unknown-channel' && status !== 'ambiguous') { - sendingTurn?.engaged.add(channelId ?? target); + sendingTurn?.engaged.add(destinationKey({ serverId: channelServerId ?? serverId, channelId: channelId ?? target })); } if (status === 'opened') { console.error( @@ -27295,7 +27528,7 @@ export class AgentFramework { ); this.recordChannelAutoOpenNotice( agentName, - serverId, + channelServerId ?? serverId, [{ channelId: channelId ?? target, label }], 'opened-by-reply', ); @@ -27313,7 +27546,7 @@ export class AgentFramework { `[channel] ${agentName}: ${toolName} delivered to ${target} but the open-on-send failed` + (tell ? '; the agent is told' : ''), ); - if (tell) this.recordReplyOpenFailedNotice(agentName, serverId, { channelId: channelId ?? target, label }, selectedForOpening); + if (tell) this.recordReplyOpenFailedNotice(agentName, channelServerId ?? serverId, { channelId: channelId ?? target, label }, selectedForOpening); } }) .catch((err) => console.error('[channel] open-on-send error:', err)); @@ -27497,17 +27730,65 @@ export class AgentFramework { }); }; + // The home quoted for a sentence (quotedShown), as the agent names it + // back, whose id-and-server form carries its own quotes. + const homeQuoted = this.channelRegistry?.quotedShown(home) ?? JSON.stringify(home.channelId); + // A home its server no longer registers (a channels/changed update + // removed it, or this host removed its server; a dropped connection + // keeps the entry) takes nothing, and the fork has nowhere else to + // act, so a call that names it is told that, before any advice to + // call again. + const homeGone = !!this.channelRegistry && this.channelRegistry.getDescriptor(home) === undefined; if (call.name === 'channel_open') { - reject(`This conversation is bound to channel "${home}", so it can't open other channels, and nothing was opened. Reply in "${home}".`); + reject(homeGone + ? `This conversation is bound to channel ${homeQuoted}, which isn't registered now; it can't open other channels, and nothing was opened.` + : `This conversation is bound to channel ${homeQuoted}, so it can't open other channels, and nothing was opened. Reply in ${homeQuoted}.`); return; } if (call.name === 'channel_publish' || call.name === 'channel_close') { - const input = (call.input ?? {}) as { channelId?: string }; - if (!input.channelId) { - call = { ...call, input: { ...input, channelId: home } }; - } else if (input.channelId !== home) { + const input = (call.input ?? {}) as { channelId?: string; serverId?: string }; + // An empty serverId is read as none given, as the channel tools and + // channelNamed read it. + const serverId = input.serverId || undefined; + const done = call.name === 'channel_publish' ? 'posted' : 'closed'; + // A fork acts only on its home, so a name its home answers to means + // its home, on the home's server, even where another server's + // channel shares the id: the home's id, or its server-qualified name, + // or no channelId at all. A serverId given with a channelId names + // that exact pair, as every selector reads it, so beside one only + // the home's own id names the home; a serverId given alone has to be + // the home's. + const namesHome = !input.channelId + ? serverId === undefined || serverId === home.serverId + : serverId === undefined + ? input.channelId === home.channelId || input.channelId === qualifiedChannelName(home) + : serverId === home.serverId && input.channelId === home.channelId; + // The home's qualified name beside the home's own serverId names no + // channel, unless that pair is another registered channel, which the + // fork can't act on; any other serverId beside it names another + // server's channel. Those get the not-allowed refusal below, naming + // what the call asked for. + const homeNameWithItsServer = !namesHome && serverId === home.serverId + && input.channelId === qualifiedChannelName(home) + && this.channelRegistry?.getDescriptor({ serverId, channelId: input.channelId }) === undefined; + if ((namesHome || homeNameWithItsServer) && homeGone) { + reject(`This conversation is bound to channel ${homeQuoted}, which isn't registered now, so nothing was ${done}, ` + + 'and nothing can be until its server registers it again.'); + return; + } + if (homeNameWithItsServer) { + reject(`${JSON.stringify(input.channelId)} names this conversation's channel by itself, so nothing was ${done}: ` + + 'call again with serverId left out.'); + return; + } + if (namesHome) { + call = { ...call, input: { ...input, channelId: home.channelId, serverId: home.serverId } }; + } else { const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing'; - reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`); + const asked = input.channelId + ? `${JSON.stringify(input.channelId)}${serverId !== undefined ? ` on server ${JSON.stringify(serverId)}` : ''}` + : `a channel on server ${JSON.stringify(serverId)}`; + reject(`This conversation is bound to channel ${homeQuoted}; ${verb} ${asked} is not allowed.`); return; } } @@ -27523,9 +27804,12 @@ export class AgentFramework { // a non-silent resume that does, a budget restart batched with an // ordinary wake, starts only once its over-budget boundary has every // result of the round. - const callingTurn = this.routingOf(agentName); + // The turn the call was made in, none for one made outside it (a script + // left running, or one an earlier turn started): only the turn's own call + // moves its routing (callingTurnOf). + const callingTurn = this.callingTurnOf(agentName, call); - this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) + this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [] }) .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ @@ -27547,11 +27831,16 @@ export class AgentFramework { // window notice. lastAnnouncedLocus tracks it so announce-on-change // stays coherent. Locus-mode agents only: explicit mode has no pin. if (call.name === 'channel_open' && result?.success) { - const opened = - (result.data as { channelId?: string } | undefined)?.channelId - ?? (call.input as { channelId?: string } | undefined)?.channelId; - if (opened) { + // The channel the open resolved, with its server, as its result + // names it. + const data = result.data as { channelId?: string; serverId?: string } | undefined; + const asked = call.input as { channelId?: string; serverId?: string } | undefined; + const opened = data?.channelId && data.serverId + ? { serverId: data.serverId, channelId: data.channelId } + : asked?.channelId ? this.channelNamed(asked.channelId, asked.serverId) : undefined; + if (opened && callingTurn) { callingTurn.triggerChannel = opened; + noteTurnChannel(callingTurn, opened); const openerAgent = this.agents.get(agentName); // The move is one this result tells the agent, so it needs the // turn that called still open. A result that lands after the turn @@ -27564,6 +27853,7 @@ export class AgentFramework { && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid') && this.openLogical.get(agentName) === callingTurn) { callingTurn.locus = opened; + noteTurnChannel(callingTurn, opened); this.lastAnnouncedLocus.set(agentName, opened); const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened); result = { @@ -27576,7 +27866,7 @@ export class AgentFramework { }, }; console.error( - `[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`, + `[routing] ${agentName}: channel_open -> pin moved to ${qualifiedChannelName(opened)} (announced in tool result)`, ); } } @@ -27759,23 +28049,41 @@ export class AgentFramework { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number; }; - const channelId = String(input.channelId ?? ''); - const entry = this.channelRegistry?.listChannelsRaw() - .find((e) => e.descriptor.id === channelId); + const name = String(input.channelId ?? ''); + // Inside the calling turn, a name one of the turn's own channels answers + // to by its id means that channel, as for the channel tools + // (ChannelRegistry.ownChannelFor). Otherwise the one channel the name + // means, which may be server-qualified; a name two servers' channels + // answer to is refused with them as candidates, never taken to be the + // first registered. + const mine = this.channelRegistry?.ownChannelFor(name, this.callOwnChannels(agentName, call)); + const named = mine ? ('channel' in mine ? [mine.channel] : []) : this.channelRegistry?.channelsNamed(name) ?? []; + const shared = mine && 'error' in mine ? mine.error + : named.length > 1 ? this.channelRegistry?.sharedNameRefusalFor(name) : undefined; + const channel = named.length === 1 ? named[0]! : undefined; + const channelId = channel?.channelId ?? name; + // The name the agent knows the channel by: its id, or where another + // server shares the id, its server-qualified name. An answer that waits + // for the transport reads it again then, since a registration meanwhile + // can change which name picks the channel. + const shownNow = (): string => (channel ? this.channelRegistry?.nameShown(channel) ?? channelId : name); + const shown = shownNow(); let result: { success: boolean; data?: unknown; error?: string; isError?: boolean }; // An open that throws is answered too, never left unhandled. const openThrew = (err: unknown): string => 'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' + `failed: ${thrownText(err)}`; - if (!entry) { - result = { success: false, error: `unknown channel: ${channelId}`, isError: true }; + if (shared) { + result = { success: false, error: shared, isError: true }; + } else if (!channel) { + result = { success: false, error: `unknown channel: ${name}`, isError: true }; } else if (input.mode === 'cancel') { - const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request'); + const r = coordinator.cancel(channel.serverId, channelId, 'agent-tool', 'agent request'); result = r.ok - ? { success: true, data: { cancelled: channelId } } + ? { success: true, data: { cancelled: shown } } : { success: false, error: r.error, isError: true }; } else { - const r = coordinator.enter(entry.serverId, channelId, { + const r = coordinator.enter(channel.serverId, channelId, { cadenceSeconds: input.cadenceSeconds, backlogCap: input.backlogCap, maxWakes: input.maxWakes, @@ -27783,7 +28091,6 @@ export class AgentFramework { }, 'agent-tool'); if (r.ok) { const data = { - tunedOut: channelId, cadenceSeconds: r.params.cadenceSeconds, backlogCap: r.params.backlogCap, maxWakes: r.params.maxWakes, @@ -27796,7 +28103,7 @@ export class AgentFramework { // known, or that the tune-out isn't in effect any more. A rejection is // answered too, never left unhandled, and the result goes through // pushEvent, as every other tool's does. - void this.channelRegistry!.openForTuneOut(entry.serverId, channelId) + void this.channelRegistry!.openForTuneOut(channel.serverId, channelId) .catch(openThrew) .then((openError) => { this.pushEvent({ @@ -27805,7 +28112,7 @@ export class AgentFramework { toolName: call.name, agentName, moduleName: 'tune-out', - result: { success: true, data: { ...data, ...(openError ? { openError } : {}) } }, + result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } }, }); }); return; @@ -27816,17 +28123,17 @@ export class AgentFramework { // openForTuneOut, which joins the open in flight or waits and, where // this host can open the channel, opens it again; the refusal says // what its transport is now. - const tuneOut = this.channelRegistry!.getTuneOutState(entry.serverId, channelId); - if (r.alreadyTunedOut && tuneOut && !this.channelRegistry!.tuneOutTransportOpen(entry.serverId, channelId)) { - void this.channelRegistry!.openForTuneOut(entry.serverId, channelId) + const tuneOut = this.channelRegistry!.getTuneOutState(channel.serverId, channelId); + if (r.alreadyTunedOut && tuneOut && !this.channelRegistry!.tuneOutTransportOpen(channel.serverId, channelId)) { + void this.channelRegistry!.openForTuneOut(channel.serverId, channelId) .catch(openThrew) .then((openError) => { // The tune-out is read again: one that ended or was replaced while // the open was out isn't described as standing, whatever the open // answered for it. - const now = this.channelRegistry!.getTuneOutState(entry.serverId, channelId); + const now = this.channelRegistry!.getTuneOutState(channel.serverId, channelId); const error = now?.params.epochId === tuneOut.params.epochId - ? alreadyTunedOutText(channelId, now.params, + ? alreadyTunedOutText(shownNow(), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.') : TUNE_OUT_REPLACED; this.pushEvent({ @@ -27881,19 +28188,33 @@ export class AgentFramework { : seconds >= 60 ? `${Math.round(seconds / 60)}m` : `${Math.round(seconds)}s`; - // Announce in the TURN's frozen locus (best-effort; never blocks the - // result). The sleep tool runs mid-turn, so the pin is live — same - // authority as every other speech path, no live re-resolution. + // Announce in the calling TURN's frozen locus (best-effort; never blocks + // the result). The sleep tool runs mid-turn, so the pin is live — same + // authority as every other speech path, no live re-resolution. A call + // 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 agent = this.agents.get(agentName); - const silentTurn = this.activeTurnTriggers.get(agentName)?.suppressProse === true; - if (silentTurn || agent?.proseRouting === 'disabled') { + const callingTurn = this.callingTurnOf(agentName, call); + // The latest start's trigger is the calling turn's only where the + // calling turn is the latest start's routing. A call matched to the + // agent's open turn under a later start's routing (callingTurnOf) is + // the open turn's, and a silent start doesn't make its routing the + // open turn's (openLogical), so the later start's trigger isn't read + // for it. + const silentTurn = callingTurn === this.turnRouting.get(agentName) + && this.activeTurnTriggers.get(agentName)?.suppressProse === true; + if (!callingTurn) { + console.error(`[sleep] ${agentName}: the call was made outside the agent's current turn — sleep announcement not posted`); + } else if (silentTurn) { + console.error(`[sleep] ${agentName}: the turn was started silent — sleep announcement not posted`); + } else if (agent?.proseRouting === 'disabled') { console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`); } else if (agent?.proseRouting === 'explicit') { // Explicit mode: announce to the turn's sticky prose target if the // model has set one; otherwise stay quiet — never guess a channel. - const target = this.turnRouting.get(agentName)?.pin; + const target = callingTurn.pin; if (target) { this.channelRegistry.routeSpeech(agentName, text, target).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); @@ -27902,7 +28223,7 @@ export class AgentFramework { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } } else { - const locus = this.turnRouting.get(agentName)?.locus ?? null; + const locus = callingTurn.locus ?? null; this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); @@ -28850,6 +29171,13 @@ export class AgentFramework { }); } + /** The channel context one server is told for an agent's inference + * (context/beforeInference, §14.4): only that server's own channels + * (ChannelRegistry.buildChannelContext). */ + private channelsForServer(agentName: string, serverId: string): ChannelContext | undefined { + return this.channelRegistry?.buildChannelContext(agentName, serverId); + } + private buildBeforeInferenceParams(agent: Agent, trigger?: InferenceRequest): BeforeInferenceParams { const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return { @@ -28869,7 +29197,8 @@ export class AgentFramework { // are omitted rather than fabricated — the hardcoded 200000 was false // for residents configured at 300k/600k. model: { id: agent.model }, - channels: this.channelRegistry?.buildChannelContext(agent.name), + // `channels` is each server's own (channelsForServer): the + // orchestrator gives each server the channel context of its channels. }; } } diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 9480007cd..81903f273 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -131,6 +131,10 @@ export interface WakeProvenance { * (2026-09-30: a DM reply published into a guild channel). Ambient-only * batches still set nothing and keep the legacy fallback. */ routeChannelId?: string; + /** The server of `routeChannelId`'s channel, beside it whenever the chosen + * event named its server: channel ids are unique only within one + * server's connection (MCPL §14.2). */ + routeServerId?: string; /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ at?: number; @@ -236,7 +240,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin ...(channelId ? { channelId } : {}), ...(pick.authorId ? { counterparty: `${ns}:user:${pick.authorId}` } : {}), ...(pick.addressed ? { addressed: true } : {}), - ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}), + ...(pick.addressed && pick.routeChannelId + ? { routeChannelId: pick.routeChannelId, ...(pick.serverId ? { routeServerId: pick.serverId } : {}) } + : {}), at: pick.timestamp, }; } @@ -633,6 +639,7 @@ export class EventGate { private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance) => void; private getAgentNamesFn: () => string[]; private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined; + private channelShownFn?: (channel: { serverId: string; channelId: string }) => string; /** Clock injection — keeps the new rate_limit / passive_sample paths * testable without monkey-patching Date.now globally. */ private now: () => number; @@ -658,6 +665,11 @@ export class EventGate { * how push-event origins compose into channel ids). Default: a * channel-incoming event's own channel id; nothing for push events. */ resolveRouteChannel?: (info: GateEventInfo) => string | undefined; + /** How the agent is shown a channel, its server's and its id (the + * framework's ChannelRegistry.nameShown): a batched wake's summary names + * each channel so, since two servers' channels can share an id. Default: + * the id. */ + channelShown?: (channel: { serverId: string; channelId: string }) => string; }) { this.configPath = opts.configPath; this.privilegedUsersPath = opts.privilegedUsersPath; @@ -666,6 +678,7 @@ export class EventGate { this.requestInferenceFn = opts.requestInference; this.getAgentNamesFn = opts.getAgentNames; this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel; + this.channelShownFn = opts.channelShown; this.now = opts.now ?? (() => Date.now()); this.loadPrivileged(); @@ -1575,24 +1588,35 @@ export class EventGate { const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`); if (referenced.length > 0) { - const byChannel = new Map(); + // By channel, its server and its id: two servers' channels can share + // an id. + const byChannel = new Map(); for (const e of referenced) { // The registered id when known, so this line names the channel the // same way routing notices and send tools do (a push event's own - // channelId is the adapter's raw id). - const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)'; - const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel }; + // channelId is the adapter's raw id), shown as the agent names it + // back where the event names its server. + // Only a registered id is a channel name; an adapter's raw id is + // shown as it is. + const id = e.routeChannelId ?? e.channelId; + const name = id === undefined + ? '(unknown channel)' + : e.routeChannelId !== undefined && e.serverId !== undefined && this.channelShownFn + ? this.channelShownFn({ serverId: e.serverId, channelId: e.routeChannelId }) + : id; + const key = JSON.stringify([e.serverId ?? null, id ?? null]); + const entry = byChannel.get(key) ?? { name, count: 0, oldest: e.timestamp, label: e.channelLabel }; entry.count += 1; entry.oldest = Math.min(entry.oldest, e.timestamp); entry.label = entry.label ?? e.channelLabel; byChannel.set(key, entry); } - for (const [channelId, { count, oldest, label }] of byChannel) { + for (const { name, count, oldest, label } of byChannel.values()) { // Prefer the human-readable name; keep the id parenthesized so the // agent can still address tools that want the composite id. A name // may carry its own `#`, so one is added only where the name lacks // it, as at the other places that add a `#`. - const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId; + const channel = label ? `${label.startsWith('#') ? label : `#${label}`} (${name})` : name; const age = Math.round((Date.now() - oldest) / 1000); lines.push( `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` + diff --git a/src/index.ts b/src/index.ts index c42a31c4d..c24db4dd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior // MCPL channel registry (exposed for modules that need channel-level operations) export { ChannelRegistry } from './mcpl/index.js'; -export type { PublishRefusalReason } from './mcpl/index.js'; +export type { ChannelRef, PublishRefusalReason } from './mcpl/index.js'; // Where each accepted inbound item came from (`metadata.inboundSource`), and // the digest its delivered and stored bodies are stamped with diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 5c37a0b0b..424e5594f 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -46,6 +46,7 @@ import { CapabilityGrant } from './capability-grant.js'; import type { InboundSource } from './inbound-source.js'; import { McplRequestError, type McplServerConnection } from './server-connection.js'; import { readSkipReplyWake } from './skip-reply-wake.js'; +import { isProseTargetToken } from './prose-grammar.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -367,7 +368,7 @@ function shallowEqualRecord( // Internal Types // ============================================================================ -/** A registered channel entry, keyed by `{serverId}:{channelId}`. */ +/** A registered channel entry, keyed by its server and id (destinationKey). */ interface ChannelEntry { serverId: string; descriptor: ChannelDescriptor; @@ -375,6 +376,11 @@ interface ChannelEntry { open: boolean; } +/** The ref naming a registered channel's entry. */ +function refOf(entry: ChannelEntry): ChannelRef { + return { serverId: entry.serverId, channelId: entry.descriptor.id }; +} + /** * A channel its server declared inbound-only (SPEC §14.2 `direction`): its * messages come in, and the host publishes nothing into it. No publish, from @@ -637,21 +643,53 @@ export function connectorText(value: unknown): string { return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text; } -/** Where a publish was resolved to go — named by the registry, never by - * the caller's spelling. */ -export interface PublishDestination { +/** + * A registered channel, by its server and its id. MCPL requires a channel id + * to be unique only within one server's connection (§14.2), so two servers + * can each register `general`, and only the pair names one of them. + */ +export interface ChannelRef { serverId: string; channelId: string; +} + +/** Where a publish was resolved to go — named by the registry, never by + * the caller's spelling. */ +export interface PublishDestination extends ChannelRef { /** The channel's registered label when the attempt was made. */ label?: string; } -/** One key per destination, its server and channel: unlike joining the two - * with a separator, no two destinations share one, whatever their ids hold. */ -export function destinationKey(to: PublishDestination): string { +/** One key per channel, its server and id: unlike joining the two with a + * separator, no two channels share one, whatever their ids hold. */ +export function destinationKey(to: ChannelRef): string { return JSON.stringify([to.serverId, to.channelId]); } +/** Whether two refs name the same channel. Two absent refs are the same: + * neither names a channel. */ +export function sameChannel(a: ChannelRef | null | undefined, b: ChannelRef | null | undefined): boolean { + if (!a || !b) return !a && !b; + return a.serverId === b.serverId && a.channelId === b.channelId; +} + +/** + * A channel's server-qualified name, `serverId:channelId`. A name given as a + * string (a tool's channelId, a `>>` target, a host's call) may take this + * form to pick one server's channel where several servers register the id. + * It is looked up among the registered pairs, never split at a colon, since + * ids hold colons (ChannelRegistry.entriesNamed). + */ +export function qualifiedChannelName(ref: ChannelRef): string { + return `${ref.serverId}:${ref.channelId}`; +} + +/** A channel told apart by its id and its server, for one no string picks + * alone: `"b:room" on server "a"`. No input takes it for a name. */ +function channelOnServer(ref: ChannelRef): string { + return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`; +} + /** * What one channels/publish attempt established (ChannelRegistry.publish). * - `delivered`: the connector returned `delivered: true`. A historical @@ -684,7 +722,9 @@ export interface PublishOutcome { /** * Why the destination refused a publishForAgent before anything went out. * Its words say the same, since both come from one check: the channel isn't - * registered here, it takes no posts, or its server is gone, this host is + * registered here, the name given means more than one registered channel + * (`shared-id`: a ref picks one, as a server-qualified name does where it names + * one channel alone), it takes no posts, or its server is gone, this host is * connecting to it, it's reconnecting, or it's up without channels.publish * (serverReach); or the request wasn't sent, as its connection refused it * before anything was written (McplRequestError's `not-sent`). A refusal of @@ -694,6 +734,7 @@ export interface PublishOutcome { */ export type PublishRefusalReason = | 'channel-not-registered' + | 'shared-id' | 'inbound-only' | 'server-gone' | 'server-connecting' @@ -813,30 +854,6 @@ function supersededClause(action: 'open' | 'close', now: DesiredChannelState | u return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first'; } -/** What ends a tune-out, for a channel tool's answer about a tuned-out - * channel (`channel` as the answer shows it, quoted): its cancel, with the - * backlog it delivers, and, for a close, that channel_close closes the - * channel once the cancel has ended the tune-out. */ -function tunedOutPointer(tool: string, channel: string): string { - return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` + - 'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.'); -} - -/** A channel tool's refusal of a tuned-out channel, whose cancel is what ends - * the tune-out, with the backlog it delivers. As an error result the agent - * reads the pointer itself; `data` names the refusal for an in-process - * caller. */ -function tunedOutRefusal(tool: string, channelId: string): ToolResult { - return { - success: false, - isError: true, - error: - `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` + - tunedOutPointer(tool, `"${channelId}"`), - data: { refusal: 'tuned-out', channelId }, - }; -} - /** A clause as the start of a sentence. */ function sentenceStart(clause: string): string { return clause.charAt(0).toUpperCase() + clause.slice(1); @@ -953,6 +970,8 @@ interface ChannelRegistryOptions { onRouteFailure?: (info: { conversationId: string; channelId: string | null; + /** The channel's server, beside `channelId` whenever the failure names a channel. */ + serverId?: string; reason: string; textLen: number; /** `failed`: nothing was posted; `unknown`: the request was dispatched @@ -991,7 +1010,7 @@ interface ChannelRegistryOptions { * whichever channel last spoke (item 3). Returns undefined for the trunk/primary agent, which has * no home and correctly falls back to the global locus (heartbeats, etc.). */ - homeChannelResolver?: (agentName: string) => string | undefined; + homeChannelResolver?: (agentName: string) => ChannelRef | undefined; /** * Resolve an agent's channel for its CURRENT inference turn, by agent name: * the turn's locus once it has one, else the channel that triggered the @@ -1006,9 +1025,11 @@ interface ChannelRegistryOptions { * turn's locus is resolved, so that * resolution takes the global fallback, which the turn then keeps. * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE - * `defaultPublishChannel`. + * `defaultPublishChannel`. Null where the turn deliberately has no channel, + * as one woken for a channel id two servers register, given without its + * server: that takes no fallback either. */ - activeChannelResolver?: (agentName: string) => string | undefined; + activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined; /** * Whether a conversation takes the process-global fallback, the channel of * the most recent delivered inbound message: as where its plain speech lands @@ -1045,7 +1066,15 @@ interface ChannelRegistryOptions { */ export type ChannelToolOrigin = | { kind: 'module'; moduleName?: string } - | { kind: 'agent'; agentName: string }; + | { + kind: 'agent'; + agentName: string; + /** For a call made inside one of the agent's turns, that turn's own + * channels, every channel that has been its trigger or its locus: a + * name one of them answers to by its id means that channel + * (ChannelRegistry.ownChannelNamed). */ + own?: readonly ChannelRef[]; + }; /** The machine decision sources a module-origin channel_close may record. * Closed on purpose: honest provenance means a small, auditable vocabulary, @@ -1071,13 +1100,17 @@ export class ChannelRegistry { source: 'subscription-policy' | 'opened-by-delivery'; channels: Array<{ channelId: string; label?: string }>; }) => void; - private homeChannelResolver?: (agentName: string) => string | undefined; - private activeChannelResolver?: (agentName: string) => string | undefined; + private homeChannelResolver?: (agentName: string) => ChannelRef | undefined; + private activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined; private takesGlobalFallback?: (conversationId: string) => boolean; private serverConnecting?: (serverId: string) => boolean; private store?: JsStore; - /** Registered channels, keyed by `{serverId}:{channelId}`. */ + /** + * Registered channels, keyed by their server and id (destinationKey): a + * key that joined the two with a separator would let server `a`'s channel + * `b:room` and server `a:b`'s channel `room` share one entry. + */ private channels = new Map(); /** @@ -1146,17 +1179,19 @@ export class ChannelRegistry { * reaction removal, edit or deletion marker, apart from an edit the * coalescer delivers on acceptance as the agent's first view: used for * speech routing and the default publish. */ - private defaultPublishChannel: string | null = null; + private defaultPublishChannel: ChannelRef | null = null; /** The same message's id and thread, used for buildChannelContext. */ private defaultPublishMessageId: string | null = null; private defaultPublishThreadId: string | undefined = undefined; - /** Per-channel typing indicator timers. */ - private typingIntervals = new Map>(); + /** Per-channel typing indicator timers, by destinationKey, with the + * channel each one types in. */ + private typingIntervals = new Map }>(); - /** Per-channel typing metadata — carried on the 7s refresh so the target - * server keeps getting the same routing hints (e.g. Zulip topic). */ + /** Per-channel typing metadata, by destinationKey — carried on the 7s + * refresh so the target server keeps getting the same routing hints (e.g. + * Zulip topic). */ private typingMetadata = new Map>(); /** Chronicle-projected desired lifecycle state, keyed by server + channel. @@ -1307,7 +1342,8 @@ export class ChannelRegistry { err: unknown, opts?: { history?: boolean }, ): string { - const channel = `"${entry.descriptor.id}"`; + // As the agent names it back (nameShown), where another server shares its id. + const channel = this.quotedShown(refOf(entry)); const tool = action === 'open' ? 'channel_open' : 'channel_close'; const done = action === 'open' ? 'opened' : 'closed'; const later = laterClause(entry); @@ -1319,7 +1355,7 @@ export class ChannelRegistry { } const failure = err instanceof ChannelLifecycleError ? err : undefined; const now = this.getDesiredState(entry.serverId, entry.descriptor.id); - const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); + const current = this.entryAt(refOf(entry)); if (!current) { // Its registration may have gone at the server's word or at this // host's (removeServer). Only a failure that says which (cancelled, @@ -1342,13 +1378,13 @@ export class ChannelRegistry { if (action === 'open' ? now !== 'open' : now !== 'closed') { if (action === 'open' && now === 'tuned-out') { return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` + - `change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`; + `change that. ${this.tuneOutEndText(current)}`; } // channel_close refuses a tuned-out channel too, so calling it again // isn't the way: the tune-out's cancel is, and it delivers the backlog. if (action === 'close' && now === 'tuned-out') { return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` + - `change that. ${tunedOutPointer('channel_close', channel)}`; + `change that. ${this.tuneOutEndText(current, 'channel_close')}`; } // The later decision's own work settles the transport, and its call // says what became of it, so this says only that this call's change @@ -1523,7 +1559,7 @@ export class ChannelRegistry { results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' }); continue; } - const key = `${serverId}:${channel.id}`; + const key = destinationKey({ serverId, channelId: channel.id }); this.registerEntry(key, serverId, channel); this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata)); registeredIds.push(channel.id); @@ -1570,9 +1606,11 @@ export class ChannelRegistry { // we never had is reported, not silently accepted. if (params.removed) { for (const channelId of params.removed) { - const key = `${serverId}:${channelId}`; - const existed = this.channels.delete(key); - this.stopTyping(channelId); + // A server may name the channel by a number: it means the channel + // whose id is that number as a string, as it always has. + const ref = { serverId, channelId: String(channelId) }; + const existed = this.channels.delete(destinationKey(ref)); + this.stopTyping(ref); addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' }); } } @@ -1588,7 +1626,7 @@ export class ChannelRegistry { this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' }); continue; } - const key = `${serverId}:${channel.id}`; + const key = destinationKey({ serverId, channelId: channel.id }); const existing = this.channels.get(key); if (!existing) { addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' }); @@ -1629,7 +1667,7 @@ export class ChannelRegistry { this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' }); continue; } - const key = `${serverId}:${channel.id}`; + const key = destinationKey({ serverId, channelId: channel.id }); this.registerEntry(key, serverId, channel); this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata)); addedResults.push({ id: channel.id, accepted: true }); @@ -1711,6 +1749,9 @@ export class ChannelRegistry { // The batch came on the transport open now: a close after this point // (transportClosed) leaves its traffic nothing to confirm. const closesAtArrival = this.transportCloses.get(serverId) ?? 0; + // And came from a server registered now: one removed since (removeServer) + // gives no message the agent replies to (markAccepted). + const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0; for (const message of params.messages) { if (!message || typeof message.channelId !== 'string' || !message.channelId @@ -1747,7 +1788,7 @@ export class ChannelRegistry { // exists registers it first; discord's DM case goes through // ensureChannelRegistered on push/event, which creates a routable // entry that isn't open, rather than an open self-attested one. - const incomingKey = `${serverId}:${message.channelId}`; + const incomingKey = destinationKey({ serverId, channelId: message.channelId }); if (!this.channels.has(incomingKey)) { // Loud, coalesced or not, like every other refusal of a message, but // at most once a minute per channel (RefusalLines): a server that @@ -1829,8 +1870,11 @@ export class ChannelRegistry { // must not retarget the default publish locus or become // `context.incoming`'s reply anchor. An edit the coalescer delivered // as the agent's first view counts as the message it replaced. - if (!isMarkerTagged(message.tags, firstView)) { - this.defaultPublishChannel = message.channelId; + // A message the coalescer admits after its server was removed sets no + // default: the removal cleared that server's message, and a server + // that's gone mustn't displace a live one's default. + if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) { + this.defaultPublishChannel = { serverId, channelId: message.channelId }; this.defaultPublishMessageId = message.messageId; this.defaultPublishThreadId = message.threadId; } @@ -1984,7 +2028,9 @@ export class ChannelRegistry { label?: string, extraMetadata?: Record, ): void { - const existing = this.findChannelEntry(channelId); + // This server's own registration: another server's channel with the same + // id is another channel (ChannelRef), which this one doesn't make. + const existing = this.entryAt({ serverId, channelId }); if (existing) { // Backfill identity onto a bare lazy registration: a DM that first // arrived with no label/recipient info gets a usable name the next @@ -2026,7 +2072,7 @@ export class ChannelRegistry { // the server may still hold a channel an earlier process opened. Counting // it confirmed closed would answer a close or a decline as already done // and send nothing, even where the server still delivers. - const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, { + const entry = this.registerEntry(destinationKey({ serverId, channelId }), serverId, { id: channelId, type: serverId, label: label ?? channelId, @@ -2047,93 +2093,104 @@ export class ChannelRegistry { // ========================================================================== /** - * Start sending typing indicators for a channel. + * Start sending typing indicators for a channel: the channel a ref names, + * or each channel a name given as a string means (entriesNamed). A host + * names channels by the ids it keeps (connectome-host's activity module), + * and typing posts nothing, so a name two servers' channels answer to + * shows typing in both, rather than in neither or in whichever registered + * first. Where two servers reach one platform channel, two bots can then + * show typing there. * * Sends a typing notification immediately and every 7 seconds thereafter. * Discord typing indicators last ~10s, so 7s keeps them alive. * - * No-op if already typing on this channel. + * No-op for a channel already typing, or not registered. */ - startTyping(channelId: string, metadata?: Record): void { + startTyping(channel: string | ChannelRef, metadata?: Record): void { + const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel]; + for (const ref of refs) this.startTypingAt(ref, metadata); + } + + private startTypingAt(ref: ChannelRef, metadata?: Record): void { + // A channel types only while it's registered, and keeps no metadata + // otherwise: each removal of a channel or a server stops its typing + // (stopTyping), which drops the metadata too, and stopAll clears both. + if (!this.entryAt(ref)) return; + const key = destinationKey(ref); const metadataChanged = metadata !== undefined && - !shallowEqualRecord(this.typingMetadata.get(channelId), metadata); + !shallowEqualRecord(this.typingMetadata.get(key), metadata); if (metadata) { - this.typingMetadata.set(channelId, metadata); + this.typingMetadata.set(key, metadata); } - if (this.typingIntervals.has(channelId)) { + if (this.typingIntervals.has(key)) { // Already typing. If the caller supplied new routing metadata (e.g. the // relevant Zulip topic just moved because a newer message arrived), // dispatch an immediate refresh so the server sees the new routing // within this request instead of waiting up to TYPING_INTERVAL_MS for // the next tick. - if (metadataChanged) { - const entry = this.findChannelEntry(channelId); - if (entry) { - this.sendTypingNotification(entry.serverId, channelId); - } - } - return; - } - - // Find the channel entry and its server - const entry = this.findChannelEntry(channelId); - if (!entry) { + if (metadataChanged) this.sendTypingNotification(ref); return; } // Send typing immediately - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(ref); // Set up interval — pulls the latest metadata on each tick so mid-stream // updates (e.g. a newer incoming message switching the relevant topic) // take effect on the next refresh. const interval = setInterval(() => { - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(ref); }, TYPING_INTERVAL_MS); - this.typingIntervals.set(channelId, interval); + this.typingIntervals.set(key, { ref, interval }); } /** * Stop sending typing indicators. * - * If channelId is specified, stops typing on that channel only. - * If no channelId, stops all typing indicators. + * Given a ref, stops typing in that channel. Given a name as a string, + * stops typing in each channel the name means, by its id or its + * server-qualified name (entriesNamed), as startTyping started it: a + * channel types only while it's registered, since startTypingAt starts + * none that isn't and each removal stops its channel's typing. Given + * nothing, stops all typing indicators. */ - stopTyping(channelId?: string): void { - if (channelId !== undefined) { - const interval = this.typingIntervals.get(channelId); - if (interval) { - clearInterval(interval); - this.typingIntervals.delete(channelId); + stopTyping(channel?: string | ChannelRef): void { + if (channel === undefined) { + // Clear all typing intervals and dispatch stop for each known channel + const running = [...this.typingIntervals.values()]; + for (const { interval } of running) clearInterval(interval); + this.typingIntervals.clear(); + for (const { ref } of running) this.sendTypingStop(ref); + this.typingMetadata.clear(); + return; + } + const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel]; + for (const ref of refs) { + const key = destinationKey(ref); + const typing = this.typingIntervals.get(key); + if (typing) { + clearInterval(typing.interval); + this.typingIntervals.delete(key); // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip) // clear the indicator immediately rather than waiting for auto-expire. // Metadata still carries the routing hint so the stop hits the same - // topic/thread as the start. Guarded by `interval`: matches the + // topic/thread as the start. Guarded by the interval: matches the // global-clear branch's semantics, and keeps defensive stopTyping(ch) // calls from spamming stops at a server that never saw a start. - const entry = this.findChannelEntry(channelId); - if (entry && this.sendTypingFn) { - this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop'); - } - } - this.typingMetadata.delete(channelId); - } else { - // Clear all typing intervals and dispatch stop for each known channel - const channels = Array.from(this.typingIntervals.keys()); - for (const interval of this.typingIntervals.values()) { - clearInterval(interval); - } - this.typingIntervals.clear(); - if (this.sendTypingFn) { - for (const id of channels) { - const entry = this.findChannelEntry(id); - if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop'); - } + this.sendTypingStop(ref); } - this.typingMetadata.clear(); + this.typingMetadata.delete(key); + } + } + + /** The explicit 'stop' for a channel whose typing ended, while its + * server still registers it. */ + private sendTypingStop(ref: ChannelRef): void { + if (this.sendTypingFn && this.entryAt(ref)) { + this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop'); } } @@ -2147,14 +2204,10 @@ export class ChannelRegistry { * deletion marker, apart from an edit the coalescer delivers on acceptance * as the agent's first view. */ - getDefaultPublishChannel(): string | null { + getDefaultPublishChannel(): ChannelRef | null { return this.defaultPublishChannel; } - /** - * Get the descriptor for a channel by its channelId (first match across - * servers). Used by the conversation router for DM classification. - */ /** * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server * declaration (channels/register, channels/changed) or an authorized open? @@ -2163,30 +2216,25 @@ export class ChannelRegistry { * registration. */ isDeclaredChannel(serverId: string, channelId: string): boolean { - const entry = this.channels.get(`${serverId}:${channelId}`); + const entry = this.entryAt({ serverId, channelId }); return !!entry && !pushPlaceholder(entry); } - getDescriptor(channelId: string): ChannelDescriptor | undefined { - return this.findChannelEntry(channelId)?.descriptor; + /** A registered channel's descriptor. Used by the conversation router for + * DM classification. */ + getDescriptor(ref: ChannelRef): ChannelDescriptor | undefined { + return this.entryAt(ref)?.descriptor; } /** The registered label of `channelId` on exactly `serverId` (no * cross-server first match), or undefined when it has none. */ getChannelLabel(serverId: string, channelId: string): string | undefined { - const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label; + const label = this.entryAt({ serverId, channelId })?.descriptor.label; return typeof label === 'string' && label.length > 0 ? label : undefined; } - /** Whether `channelId`'s transport is open: on `serverId` where the caller - * knows the channel's server, and otherwise on the first server found to - * register that id (findChannelEntry). The map's key joins the two ids - * with a colon, so another server's entry can sit under a key: it has to - * be this server's (as in streamingServerAt). */ - isChannelOpen(channelId: string, serverId?: string): boolean { - if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true; - const entry = this.channels.get(`${serverId}:${channelId}`); - return entry?.serverId === serverId && entry.open === true; + isChannelOpen(ref: ChannelRef): boolean { + return this.entryAt(ref)?.open === true; } getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined { @@ -2244,7 +2292,7 @@ export class ChannelRegistry { serverId?: string; backscroll?: number; beforeMessageId?: string; - }); + }, origin?.kind === 'agent' ? origin.own : undefined); case 'channel_close': return this.handleToolClose( @@ -2263,10 +2311,10 @@ export class ChannelRegistry { serverId?: string; messageId: string; acknowledge?: string; - }); + }, origin?.kind === 'agent' ? origin.own : undefined); case 'channel_publish': - return this.handleToolPublish(input as { channelId: string; content: string }, origin); + return this.handleToolPublish(input as { channelId?: string; serverId?: string; content?: string; text?: string }, origin); case 'think': return this.handleToolThink(input as { content?: string }); @@ -2293,13 +2341,39 @@ export class ChannelRegistry { // ========================================================================== /** - * Build channel context for inclusion in beforeInference params. - * - * Returns undefined if no channels are active. + * The message the agent is replying to, with its channel: the most recent + * inbound message that isn't a reaction, reaction removal, edit or deletion + * marker, apart from an edit the coalescer delivers on acceptance as the + * agent's first view. Left process-global: per-channel inbound tracking + * (the right messageId for a fork's own channel) is a separate concern from + * the outbound routing fix. An ephemeral run has none (takesGlobalFallback), + * so a subagent's refusal marks no one else's channel. Nor has a turn that + * deliberately has no channel (activeChannelResolver's null): woken for a + * channel id two servers register, it isn't replying to the latest inbound + * message, which may be anywhere. A fork's turn always has a channel, its + * home, so it's taken to be replying to the latest inbound message as every + * fork's turn is, in explicit mode as in locus mode, where its locus is + * its home. */ - buildChannelContext(agentName?: string): ChannelContext | undefined { - const openChannels = this.getOpenChannels(); + incomingFor(agentName?: string): { channel: ChannelRef; messageId: string; threadId?: string } | undefined { + if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) { + return undefined; + } + const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel; + if (!fallback || !this.defaultPublishMessageId) return undefined; + return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId }; + } + /** + * The channel context one server is told for an agent's inference + * (context/beforeInference, §14.4). Each server is told only of its own + * channels: ids are unique only within one server's connection (§14.2), so + * another server's channel named by its id alone could read as this + * server's own, and §14.4 leaves the field to the host. So `incoming` and + * `defaultOutgoing` appear only where that channel is `serverId`'s, and + * `candidates` lists only its open channels. Undefined when none applies. + */ + buildChannelContext(agentName: string | undefined, serverId: string): ChannelContext | undefined { // Resolve the outbound locus the SAME way routeSpeech does, so the agent is // told the channel its speech will actually land in (none, when its server // declared it inbound-only, since speech isn't posted there): a conversation @@ -2316,43 +2390,37 @@ export class ChannelRegistry { // the latest inbound message's channel as its outgoing one, nor that // message as the one it is replying to. const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel; - const outgoing = home ?? active ?? fallback; - - if (openChannels.length === 0 && !outgoing) { - return undefined; - } + // A turn that deliberately has no channel (null) takes no fallback. + const outgoing = home ?? (active !== undefined ? active : fallback); const context: ChannelContext = {}; - // Incoming: the most recent inbound message that isn't a reaction, - // reaction removal, edit or deletion marker, apart from an edit the - // coalescer delivers on acceptance as the agent's first view (what the - // agent is replying to). - // Left process-global — per-channel inbound tracking (the right messageId for - // a fork's own channel) is a separate concern from the outbound routing fix. - if (fallback && this.defaultPublishMessageId) { + // Incoming: the message the agent is replying to (incomingFor). + const incoming = this.incomingFor(agentName); + if (incoming && incoming.channel.serverId === serverId) { context.incoming = { - channelId: fallback, - messageId: this.defaultPublishMessageId, - threadId: this.defaultPublishThreadId, + channelId: incoming.channel.channelId, + messageId: incoming.messageId, + threadId: incoming.threadId, }; } // Default outgoing: the resolved outbound locus (home channel for forks), // unless its server declared it inbound-only: routeSpeech publishes nothing // there, so it isn't where the agent's speech goes. - if (outgoing && !this.inboundOnlyServer(outgoing)) { + if (outgoing && outgoing.serverId === serverId && !this.inboundOnlyServer(outgoing)) { context.defaultOutgoing = { - channelId: outgoing, + channelId: outgoing.channelId, }; } - // Candidates: all open channel IDs - if (openChannels.length > 0) { - context.candidates = openChannels.map((e) => e.descriptor.id); + // Candidates: this server's open channels. + const candidates = this.getOpenChannels().filter((e) => e.serverId === serverId).map((e) => e.descriptor.id); + if (candidates.length > 0) { + context.candidates = candidates; } - return context; + return Object.keys(context).length > 0 ? context : undefined; } // ========================================================================== @@ -2372,12 +2440,18 @@ export class ChannelRegistry { for (const [key, entry] of this.channels) { if (entry.serverId !== serverId) continue; this.channels.delete(key); - this.stopTyping(entry.descriptor.id); - if (this.defaultPublishChannel === entry.descriptor.id) { - this.defaultPublishChannel = null; - this.defaultPublishMessageId = null; - this.defaultPublishThreadId = undefined; - } + this.stopTyping(refOf(entry)); + } + // The default channel's ref stays, as a channels/changed removal leaves + // it: a call that would default to it is refused as the default gone, + // naming it, rather than told there's no default yet, and another + // server's channel with its id isn't taken for it. Its message and + // thread go, as on main, so no message on a server that's gone is given + // as the one an agent replies to (incomingFor), to that server added + // back or to a refusal's reaction. + if (this.defaultPublishChannel?.serverId === serverId) { + this.defaultPublishMessageId = null; + this.defaultPublishThreadId = undefined; } // Desired state and migration markers deliberately survive disconnects. } @@ -2422,11 +2496,13 @@ export class ChannelRegistry { * Stop all typing intervals and clear all channel registrations. */ stopAll(): void { - // Clear all typing intervals - for (const interval of this.typingIntervals.values()) { + // Clear all typing intervals, and their metadata, which a channel + // registered again later would otherwise send + for (const { interval } of this.typingIntervals.values()) { clearInterval(interval); } this.typingIntervals.clear(); + this.typingMetadata.clear(); // Clear channels map this.channels.clear(); @@ -2948,7 +3024,7 @@ export class ChannelRegistry { return this.getTuneOutState(serverId, channelId) !== null && !this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) && this.serverReach(serverId) === 'up' && - this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`)); + this.tuneOutReadsOpen(this.channels.get(destinationKey({ serverId, channelId }))); } /** @@ -2976,7 +3052,7 @@ export class ChannelRegistry { channelId: string, epochId: string | undefined, ): Promise { - const channelKey = `${serverId}:${channelId}`; + const channelKey = destinationKey({ serverId, channelId }); const lifecycleKey = this.lifecycleKey(serverId, channelId); const inEffect = () => epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId; @@ -3188,32 +3264,48 @@ export class ChannelRegistry { * the resident matches none. A refusal of the destination before anything * went out carries its `reason` (PublishRefusalReason), so a caller can act * on why nothing was posted without reading the words. + * + * The channel is a ref, or a name given as a string, which resolves as + * channel_publish's channelId does: one channel the name means, which may + * be server-qualified, and a name two servers' channels answer to is + * refused with them as candidates (`shared-id`). A host keeps channels by + * their ids (connectome-host's notices module). Where several servers + * register an id, a ref picks one server's channel, as a qualified name does + * where it names one channel alone. */ async publishForAgent( - channelId: string, + channel: string | ChannelRef, text: string, agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean; reason?: PublishRefusalReason }> { - // The server the publish goes to, as handleToolPublish finds it before - // it awaits: the channel may be removed while the reply is pending. - const serverId = this.findChannelEntry(channelId)?.serverId ?? ''; - const seen: { outcome?: PublishOutcome; refusal?: PublishRefusalReason } = {}; - const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel'); - if (seen.outcome?.status === 'delivered') { + const input = typeof channel === 'string' + ? { channelId: channel, content: text } + : { channelId: channel.channelId, serverId: channel.serverId, content: text }; + const seen: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason } = {}; + const result = await this.handleToolPublish(input, undefined, seen, 'speak_in_channel'); + // The destination the publish resolved before it awaited, which the + // outcome carries: the channel may be removed while the reply is pending. + const destination = seen.outcome?.destination; + if (seen.outcome?.status === 'delivered' && destination) { this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, - serverId, - channelId, + serverId: destination.serverId, + channelId: destination.channelId, delivered: true, textLen: text.length, text, }); } else { + // The channel the publish went to, or that the call named before a + // refusal, or, where nothing resolved, the server the caller gave, with + // the name it gave. + const named = destination ?? seen.resolved; this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId: agentName, - channelId, + ...(named ? { serverId: named.serverId } : 'serverId' in input ? { serverId: input.serverId } : {}), + channelId: named?.channelId ?? input.channelId, // The publish's own reason and the connector's detail, as // deliverSpeech's trace gives them, or the refusal before any publish. reason: seen.outcome?.reason ?? result.error ?? 'the publish failed', @@ -3347,7 +3439,7 @@ export class ChannelRegistry { removals: number, options?: { history?: ChannelHistoryRequest }, ): Promise<{ open: boolean; result?: ChannelsOpenResult }> { - const channelKey = `${serverId}:${channelId}`; + const channelKey = destinationKey({ serverId, channelId }); const lifecycleKey = this.lifecycleKey(serverId, channelId); try { let attempts = 0; @@ -3520,7 +3612,7 @@ export class ChannelRegistry { * doesn't know needs nothing: §14.5 refuses its traffic. */ private closeSubstitutedChannel(serverId: string, channelId: string): void { - if (!this.channels.has(`${serverId}:${channelId}`)) return; + if (!this.channels.has(destinationKey({ serverId, channelId }))) return; if (this.getDesiredState(serverId, channelId) !== 'closed') return; this.applyThroughQueue(serverId, channelId); } @@ -3538,7 +3630,7 @@ export class ChannelRegistry { this.applyDesiredChannelState(serverId, channelId).catch((err) => { // Removal while queued is cancellation, as in reconcileChannels, and // exhausted convergence emitted its own diagnostic. - if (!this.channels.has(`${serverId}:${channelId}`) || + if (!this.channels.has(destinationKey({ serverId, channelId })) || (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge'))) return; this.emitTraceFn({ type: 'mcpl:channel-reconcile-failed', @@ -3577,7 +3669,7 @@ export class ChannelRegistry { // loop, so stop waiting rather than spin. if (this.channelLifecycleTails.get(lifecycleKey) === pending) break; } - const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); + const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id }); if (!current) throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed'); // A close that stands is what the words met, whatever became of an open, // and whether or not the close itself took: one that failed may have @@ -3622,7 +3714,7 @@ export class ChannelRegistry { // Record all admissions before waiting on any channel. A slow first RPC // must not delay intent for the rest of this registration batch. for (const channel of channels) { - const entry = this.channels.get(`${serverId}:${channel.id}`); + const entry = this.entryAt({ serverId, channelId: channel.id }); if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) { policyOpened.push({ channelId: channel.id, label: entry.descriptor.label }); } @@ -3646,14 +3738,14 @@ export class ChannelRegistry { // Each channel has its own lifecycle queue, so a held RPC for one (up to // its timeout, times its attempts) doesn't delay its siblings. await Promise.allSettled(channels.map(async (channel) => { - if (!this.channels.has(`${serverId}:${channel.id}`)) return; + if (!this.channels.has(destinationKey({ serverId, channelId: channel.id }))) return; try { await this.applyDesiredChannelState(serverId, channel.id); } catch (err) { // Removal while queued is cancellation, not a failed subscription. // Exhausted convergence and a substituted answer already emitted // their own diagnostics. - if (!this.channels.has(`${serverId}:${channel.id}`) || + if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })) || (err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted'))) return; this.emitTraceFn({ @@ -3677,13 +3769,13 @@ export class ChannelRegistry { * Uses the sendTypingFn callback if provided. If not, this is a no-op * (typing timer lifecycle is still managed for when the callback is wired). */ - private sendTypingNotification(serverId: string, channelId: string): void { + private sendTypingNotification(ref: ChannelRef): void { // §14.1: channels/typing requires channels.typing in the grant. Silent // skip — a typing indicator is cosmetic and per-7s, so a diagnostic per // tick would be noise. - if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return; + if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing')) return; if (this.sendTypingFn) { - this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId)); + this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref))); } // TODO: When server-connection exposes a public sendNotification or // sendTyping method, wire it here directly instead of using a callback. @@ -3700,76 +3792,267 @@ export class ChannelRegistry { .map((e) => e.descriptor); } - /** Server id owning a registered channel (by descriptor id), or null. */ - getChannelServerId(channelId: string): string | null { - return this.findChannelEntry(channelId)?.serverId ?? null; - } - /** * The server that declared this channel inbound-only (isInboundOnly), or - * undefined when it isn't one. The notices name that server. A caller that - * knows the channel's server passes it; otherwise the channel is found by - * its id alone (findChannelEntry). + * undefined when it isn't one, or isn't registered. The notices name that + * server. */ - inboundOnlyServer(channelId: string, serverId?: string): string | undefined { - const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); + inboundOnlyServer(ref: ChannelRef): string | undefined { + const entry = this.entryAt(ref); return entry && isInboundOnly(entry) ? entry.serverId : undefined; } + /** A channel's entry, by its server and id: the map is keyed by destinationKey. */ + private entryAt(ref: ChannelRef): ChannelEntry | undefined { + return this.channels.get(destinationKey(ref)); + } + /** - * Find a channel entry by channelId (searches across all servers). - * Returns the first match. + * The registered channels a name given as a string can mean (a tool's + * channelId, a `>>` target, a host's call): those whose id is the name and + * those whose server-qualified name (qualifiedChannelName) is the name, + * taken together. The qualified form is matched against the registered + * pairs, never split at a colon, since ids hold colons (discord-mcpl's are + * `discord::`). So a name that two entries answer to, in + * either form, means both of them, and a caller that has to pick one + * refuses it with both as candidates rather than taking the first. */ - private findChannelEntry(channelId: string): ChannelEntry | undefined { - for (const [key, entry] of this.channels) { - if (entry.descriptor.id === channelId) { - return entry; - } + private entriesNamed(name: string): ChannelEntry[] { + return [...this.channels.values()].filter( + (e) => e.descriptor.id === name || qualifiedChannelName(refOf(e)) === name, + ); + } + + /** The channels a name given as a string means (entriesNamed), by ref: + * for a caller that has to settle a host's or a tool's name itself. */ + channelsNamed(name: string): ChannelRef[] { + return this.entriesNamed(name).map(refOf); + } + + /** + * The name to show an agent for a channel: one that picks it alone, which + * a channel tool's channelId takes back, and a `>>` target too where the + * name is one (isProseTargetToken: a single token other than `skip_reply`, + * which a `>>` line reads as private speech); proseTargetFor gives the + * target to write. A registered + * channel's is nameFor's: its id, or, where another server registers the + * same id, its server-qualified name. A channel no string picks alone, as + * where qualified names collide too, is shown by its id and its server, as + * the refusals show it (channelOnServer), which no input takes for a name. + * A channel no longer registered is shown by its id, or by its qualified + * name, only where no registered channel takes that name, as a channel + * tool reads it or a `>>` target does (by label or raw id too), so the + * name can't send anything to another channel; else by its id and server. + */ + nameShown(ref: ChannelRef): string { + const entry = this.entryAt(ref); + if (entry) return this.nameFor(entry) ?? channelOnServer(ref); + for (const name of [ref.channelId, qualifiedChannelName(ref)]) { + if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name; + } + return channelOnServer(ref); + } + + /** + * A tool's refusal of a tuned-out channel, whose decision is the tune-out: + * its cancel is what ends it (tuneOutEndText), and a decision recorded over + * it would end it without that cancel, so the backlog it diverted would + * never reach the agent. + */ + private tunedOutRefusal(tool: string, entry: ChannelEntry): ToolResult { + return { + success: false, + isError: true, + error: + `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` + + `doesn't change that. ${this.tuneOutEndText(entry, tool)}`, + data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId }, + }; + } + + /** + * How a tuned-out channel's tune-out ends, for the texts that tell an agent + * channel_open doesn't change it: a tune_out cancel, by the name that + * picks the channel (nameFor), or, where no name picks it alone, its own + * end, since tune_out takes a name. + * For channel_close, which refuses a tuned-out channel too, it adds that + * the close comes after that. + */ + private tuneOutEndText(entry: ChannelEntry, tool?: string): string { + const close = tool === 'channel_close'; + return this.nameFor(entry) !== undefined + ? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` + + `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}` + : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' + + 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' + + (close ? ' Once it has ended, channel_close can close it.' : ''); + } + + /** + * A channel's name as nameShown gives it, quoted for a sentence: the name + * in quotes, or the id-and-server form, whose parts are quoted already. + */ + quotedShown(ref: ChannelRef): string { + const shown = this.nameShown(ref); + return shown === channelOnServer(ref) ? shown : JSON.stringify(shown); + } + + /** + * The string that names exactly this entry by entriesNamed: its id when no + * other entry answers to that (the id is unique), else its server-qualified + * name when no other entry answers to that, else undefined. The last can + * happen only where qualified names collide, as server `a`'s channel + * `b:room` and server `a:b`'s channel `room` do; such a channel is named + * only by its server and id apart. + */ + private nameFor(entry: ChannelEntry): string | undefined { + for (const name of [entry.descriptor.id, qualifiedChannelName(refOf(entry))]) { + const named = this.entriesNamed(name); + if (named.length === 1 && named[0] === entry) return name; } return undefined; } - private resolveToolChannelEntry( - channelId: string, - serverId?: string, - ): { entry?: ChannelEntry; error?: string } { - const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId); - const matches = withId.filter((entry) => !serverId || entry.serverId === serverId); - if (matches.length === 0) { - if (serverId && withId.length > 0) { - // Leaving serverId out works only when one server has the id. - const servers = withId.map((entry) => `"${entry.serverId}"`); - return { - error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1 - ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.` - : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`), - }; + /** + * The refusal for a name that more than one registered channel answers + * to (entriesNamed). Where each one has a name that picks it alone + * (nameFor), as two servers' channels with one id do, it lists those. + * Where one has none, the name reads two ways, as server `a`'s `b:room` + * and server `a:b`'s `room` both answer to `a:b:room`, and the refusal + * gives a line per channel, each with its own route, or, where the tool + * has none to it, saying that no channelId reaches it alone. + * `takesServerId` says whether the tool also takes a serverId to pick + * with; `nothing` says what didn't happen, "nothing ran" unless given. + */ + private sharedNameRefusal( + name: string, + entries: ChannelEntry[], + opts: { takesServerId: boolean; nothing?: string }, + ): string { + const nothing = opts.nothing ?? 'nothing ran'; + const names = entries.map((e) => this.nameFor(e)); + if (names.every((n) => n !== undefined)) { + const listed = names.map((n) => JSON.stringify(n)).join(', '); + const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))]; + // One server's two channels can answer to one name too, as `s`'s + // `s:room` and `room` both do to `s:room`; its server picks neither. + if (servers.length === 1) { + return `Channel ${JSON.stringify(name)} could mean more than one channel on server ${servers[0]} ` + + `(${listed}), so ${nothing}. Call again with channelId set to one of them.`; } - return { - error: serverId - ? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.` - : `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`, - }; + // A serverId goes with the channel's own id, so it's offered only + // where the name is every candidate's own id. + const byServer = opts.takesServerId && entries.every((e) => e.descriptor.id === name) + ? `, or with serverId set to ${servers.join(' or ')}` + : ''; + return `Channel ${JSON.stringify(name)} is on more than one server (${listed}), so ${nothing}.` + + ` Call again with channelId set to one of them${byServer}.`; } - if (matches.length > 1) { - return { - error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` + - 'so nothing ran. Call again with serverId set to one of them.', - }; + const lines = entries.map((e, i) => { + const where = channelOnServer(refOf(e)); + const own = names[i]; + if (own === undefined && !opts.takesServerId) return `- ${where}: no channelId reaches it alone.`; + return own !== undefined + ? `- ${where}: call again with channelId ${JSON.stringify(own)}.` + : `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`; + }); + const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`; + return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`; + } + + /** + * The refusal for a name more than one registered channel answers to, for + * a tool outside the registry whose schema has no serverId (tune_out, the + * subconscious's cancel_tuneout and deliver_summary), or undefined when the + * name means one channel or none. `nothing` says what didn't happen, + * "nothing ran" unless given. + */ + sharedNameRefusalFor(name: string, nothing?: string): string | undefined { + const entries = this.entriesNamed(name); + return entries.length > 1 ? this.sharedNameRefusal(name, entries, { takesServerId: false, nothing }) : undefined; + } + + /** + * Where a channel id given with a server that doesn't have it is, for that + * refusal: the servers that register the id, each a serverId to call again + * with, and leaving serverId out only where the id alone then names one + * channel (entriesNamed), since another channel's qualified name can be + * the same string. For a publish, whose schema has no serverId + * (`takesServerId` false: a channel_publish given one the agent added, or + * a host's publishForAgent given a ref), where the id is, by the names its + * channelId takes (nameFor), and no advice to post there, since each is + * another destination, perhaps another community's. + */ + private elsewhereText(channelId: string, takesServerId = true): string { + const elsewhere = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); + const named = this.entriesNamed(channelId); + const alone = named.length === 1; + if (elsewhere.length === 0) { + // Such as a server-qualified name given with serverId too: alone, it + // names its channel, which may be another server's than the one given, + // so the text says whose. + if (!alone) return 'channel_list shows the channels and their servers.'; + const one = named[0]!; + const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server ` + + JSON.stringify(one.serverId); + return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`; } - return { entry: matches[0] }; + if (!takesServerId) { + // Each is another destination, perhaps another community's, so the + // text names where the id is, each place by the name its channelId + // takes, and leaves the choice to the caller. A place no channelId + // reaches alone says so. + const places = elsewhere.map((e) => { + const at = `on server ${JSON.stringify(e.serverId)}`; + const name = this.nameFor(e); + return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`; + }); + return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`; + } + const servers = elsewhere.map((e) => JSON.stringify(e.serverId)); + const leaveOut = alone ? ', or leave serverId out' : ''; + return servers.length === 1 + ? `It's on server ${servers[0]}: call again with serverId ${servers[0]}${leaveOut}.` + : `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`; } /** - * Find the composite key for a channel by its channelId. + * The channel a channel tool names: the channel with id `channelId` on + * `serverId` when the call gives a serverId, the exact pair, as every + * selector that gives a server reads it; otherwise the one channel its + * name means (entriesNamed), which may be server-qualified. A name more + * than one channel answers to is refused with them as candidates, never + * resolved to the first. Its callers are the tools that take a serverId + * (channel_open, channel_close, channel_decline), so the refusal offers + * one. `own` is the calling turn's own channels, for an agent's call made + * inside it (ChannelToolOrigin.own). */ - private findChannelKey(channelId: string): string | undefined { - for (const [key, entry] of this.channels) { - if (entry.descriptor.id === channelId) { - return key; - } + private resolveToolChannelEntry( + channelId: string, + serverId?: string, + own?: readonly ChannelRef[], + ): { entry?: ChannelEntry; error?: string } { + // Inside the calling turn, a name one of the turn's own channels answers + // to by its id means that channel, as a `>>` target does (ownChannelNamed): + // once its server no longer registers it, the call is refused, with no + // route to another server's channel with the id. + const mine = serverId ? undefined : this.ownChannelNamed(channelId, own); + if (mine && 'neither' in mine) return { error: this.ownNeitherRefusal(channelId, mine.neither, 'ran') }; + if (mine) { + const entry = this.entryAt(mine.channel); + return entry ? { entry } : { error: this.ownChannelGoneRefusal(channelId, mine.channel, 'other') }; } - return undefined; + if (serverId) { + const entry = this.entryAt({ serverId, channelId }); + if (entry) return { entry }; + return { error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ${this.elsewhereText(channelId)}` }; + } + const matches = this.entriesNamed(channelId); + if (matches.length === 0) { + return { error: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.` }; + } + if (matches.length > 1) return { error: this.sharedNameRefusal(channelId, matches, { takesServerId: true }) }; + return { entry: matches[0] }; } // ========================================================================== @@ -3779,6 +4062,11 @@ export class ChannelRegistry { private handleToolList(): ToolResult { const allChannels: Array<{ id: string; + /** The name that picks the channel alone (nameFor), its server-qualified + * id, where its id names another channel too, as where another server + * registers the same id. A channel no string picks alone has none; a + * channel tool that takes serverId reaches it by its id and server. */ + name?: string; type: string; label: string; direction: string; @@ -3787,9 +4075,18 @@ export class ChannelRegistry { serverId: string; }> = []; + // How many registered channels answer to each id and server-qualified + // id, in one pass, so each entry's name is nameFor's (the one of those + // that picks it alone) without a scan of every channel per entry. + const answering = new Map(); + for (const e of this.channels.values()) { + for (const n of [e.descriptor.id, qualifiedChannelName(refOf(e))]) answering.set(n, (answering.get(n) ?? 0) + 1); + } for (const entry of this.channels.values()) { + const name = [entry.descriptor.id, qualifiedChannelName(refOf(entry))].find((n) => answering.get(n) === 1); allChannels.push({ id: entry.descriptor.id, + ...(name !== undefined && name !== entry.descriptor.id ? { name } : {}), type: entry.descriptor.type, label: entry.descriptor.label, direction: entry.descriptor.direction, @@ -3911,7 +4208,7 @@ export class ChannelRegistry { // and lifted nothing. The lift is ensureInitialDesiredState's own, // which leaves every real decision alone. One that can't be recorded // leaves the default, closed. - const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); + const current = this.entryAt(refOf(entry)); let selectedAgain: Exclude | undefined; if (current) { try { @@ -3985,7 +4282,10 @@ export class ChannelRegistry { * docs/explicit-prose-routing.md) to a registered channel. * * Accepted spellings, tried in order: - * 1. exact descriptor id (`discord:guild:123`, always unambiguous) + * 1. exact descriptor id (`discord:guild:123`), or its server-qualified + * name (`a:discord:guild:123`), as entriesNamed matches them; a name + * two channels answer to, such as an id two servers register, is + * refused with both as candidates * 2. exact raw server-internal id (`address.channelId`) * 3. `#label` / bare label — case-insensitive match on descriptor label * with any leading '#' stripped from both sides @@ -4000,34 +4300,172 @@ export class ChannelRegistry { * names the channel as `refusedChannelId`, so a caller can tell a refused * channel from a name that resolved to nothing. Without it, as for reading * a channel's history, any registered channel resolves. + * + * `own` is the turn's own channels, for a target its speech names: a name + * one of them answers to by its id means that channel, on its server, + * before any other spelling (ownChannelNamed). */ resolveProseTarget( spec: string, - opts?: { forSpeech?: boolean }, - ): { channelId: string; label?: string } | { error: string; candidates?: string[]; refusedChannelId?: string } { - const resolved = this.resolveTargetByName(spec); + opts?: { forSpeech?: boolean; own?: readonly ChannelRef[] }, + ): PublishDestination | { error: string; candidates?: string[]; refusedChannelId?: string } { + const own = this.ownChannelNamed(spec.trim(), opts?.own); + let resolved: PublishDestination | { error: string; candidates?: string[] }; + if (own === undefined) { + resolved = this.resolveTargetByName(spec); + } else if ('neither' in own) { + resolved = { error: `"${spec.trim()}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.text) }; + } else { + const entry = this.entryAt(own.channel); + resolved = entry + ? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label } + : { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` }; + } if (!opts?.forSpeech || 'error' in resolved) return resolved; - const entry = this.findChannelEntry(resolved.channelId); + const entry = this.entryAt(resolved); return entry && isInboundOnly(entry) - ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId } + ? { error: `${this.nameShown(resolved)} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId } : resolved; } + /** + * The turn's own channel a name means: where the name is the id of one of + * the turn's own channels, a channel that has been its trigger or its + * locus, that channel, on its server, whether or not that server still + * registers it. A message's + * reply goes where the message came from, so another server that + * registers the id, or takes it once the message's server withdrew it, + * isn't reached through the name. Where the turn's channels with that id + * are on more than one server, the name means neither. Undefined where no + * channel of the turn's has that id. + */ + private ownChannelNamed( + name: string, + own: readonly ChannelRef[] | undefined, + ): { channel: ChannelRef } | { neither: ChannelRef[] } | undefined { + const named: ChannelRef[] = []; + for (const c of own ?? []) { + if (c.channelId === name && !named.some((n) => sameChannel(n, c))) named.push(c); + } + if (named.length === 0) return undefined; + return named.length === 1 ? { channel: named[0]! } : { neither: named }; + } + + /** Why a name the turn's own channel answers to (ownChannelNamed) reaches + * nothing once that channel's server no longer registers it. It offers no + * other channel with the id, since that's another server's. */ + private ownChannelGoneText(name: string, channel: ChannelRef): string { + return `"${name}" is this turn's channel on server "${channel.serverId}", which no longer registers it`; + } + + /** + * A name the calling turn's own channels answer to by their id, for a tool + * outside the registry that takes a channel's name (tune_out, and the + * subconscious's cancel_tuneout, speak_in_channel and deliver_summary), + * read as the registry's channel tools read it: the turn's channel, or a + * refusal where its server no longer registers it, or where the name is + * the id of the turn's channels on more than one server. Undefined where + * none of the turn's channels has that id. `tool` words the refusal for + * what the tool would have done: posted (speak_in_channel), delivered + * (deliver_summary) or run (the others). + */ + ownChannelFor( + name: string, + own: readonly ChannelRef[] | undefined, + tool: 'publish' | 'deliver' | 'other' = 'other', + ): { channel: ChannelRef } | { error: string } | undefined { + const mine = this.ownChannelNamed(name, own); + if (!mine) return undefined; + if ('neither' in mine) { + const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran'; + return { error: this.ownNeitherRefusal(name, mine.neither, done) }; + } + return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) }; + } + + /** A refusal of a name the turn's own channel answers to, once its server + * no longer registers it: nothing was posted (channel_publish, + * speak_in_channel), delivered (deliver_summary) or ran (the other channel + * tools, tune_out, cancel_tuneout), and nothing can be until it does + * again. */ + private ownChannelGoneRefusal(name: string, channel: ChannelRef, tool: 'publish' | 'deliver' | 'other'): string { + const outcome = tool === 'publish' ? 'nothing was posted, and nothing can be' + : tool === 'deliver' ? 'nothing was delivered, and nothing can be' : 'nothing ran, and nothing can run on it'; + return `${this.ownChannelGoneText(name, channel)}, so ${outcome} until that server registers it again.`; + } + + /** + * The turn's channels a name means neither of (ownChannelNamed), each by a + * string that reaches it outside the turn's rule: its server-qualified + * name, where that picks it alone, since its id is the name refused. One + * no string picks alone, and one its server no longer registers, is named + * by its id and server, saying so; `reaches` says which a name reaches. + */ + private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; reaches: boolean }> { + return channels.map((c) => { + const entry = this.entryAt(c); + if (!entry) return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false }; + const qualified = qualifiedChannelName(c); + const picked = this.entriesNamed(qualified); + return picked.length === 1 && picked[0] === entry + ? { text: qualified, reaches: true } + : { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false }; + }); + } + + /** A tool's refusal of a name the turn's channels on more than one server + * answer to by their id (ownChannelNamed's neither). */ + private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string): string { + const candidates = this.ownCandidates(channels); + const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text)); + const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.' + : named.length === 1 ? ` Call again with channelId ${named[0]}.` + : named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : ''; + const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', '); + return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`; + } + private resolveTargetByName( spec: string, - ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { + ): PublishDestination | { error: string; candidates?: string[] } { const entries = [...this.channels.values()]; const trimmed = spec.trim(); if (!trimmed) return { error: 'empty target' }; + const hit = (e: ChannelEntry): PublishDestination => + ({ channelId: e.descriptor.id, serverId: e.serverId, label: e.descriptor.label }); + // A candidate is named by the token that picks it alone (nameFor), so + // two servers' channels with one id or one label can be told apart, or, + // where no string picks it alone, by its id and its server. + const token = (e: ChannelEntry): string => this.nameFor(e) ?? channelOnServer(refOf(e)); + // A DM is shown by its label, the person, where that label, given as a + // target, picks that DM. It doesn't where another channel answers to that + // label too, as two servers' DMs with one person do (a label is matched + // ignoring case, so "DM: Antra" and "DM: antra" both answer), nor where + // an id, a raw id or another label outranks it, so a bounce's example or + // a resend's destination never names another channel. Those are shown by + // their tokens instead. A label read as a mention or an @name would come + // back here, so it's shown by its token too. + const labelPicks = (e: ChannelEntry, label: string): boolean => { + if (/^@|^<@!?\d+>$/.test(label.trim())) return false; + const r = this.resolveTargetByName(label); + return 'channelId' in r && sameChannel(r, refOf(e)); + }; + const dmShown = (list: ChannelEntry[]): string[] => list.map((e) => { + const label = e.descriptor.label ?? e.descriptor.id; + return labelPicks(e, label) ? label : token(e); + }); - const exact = entries.filter((e) => e.descriptor.id === trimmed); + // A channel id, or its server-qualified name (entriesNamed). + const exact = this.entriesNamed(trimmed); if (exact.length === 1) { - return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label }; + return hit(exact[0]!); } if (exact.length > 1) { return { - error: `channel id "${trimmed}" is ambiguous across servers`, - candidates: exact.map((e) => `${e.serverId}:${e.descriptor.id}`), + error: new Set(exact.map((e) => e.serverId)).size > 1 + ? `channel id "${trimmed}" is ambiguous across servers` + : `channel id "${trimmed}" names more than one channel on server "${exact[0]!.serverId}"`, + candidates: exact.map(token), }; } @@ -4035,12 +4473,12 @@ export class ChannelRegistry { (e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed, ); if (raw.length === 1) { - return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label }; + return hit(raw[0]!); } if (raw.length > 1) { return { error: `raw id "${trimmed}" is ambiguous`, - candidates: raw.map((e) => e.descriptor.id), + candidates: raw.map(token), }; } @@ -4071,11 +4509,15 @@ export class ChannelRegistry { const id = mention[1]!; const byId = dms.filter((e) => dmMeta(e)?.recipientId === id); if (byId.length === 1) { - return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label }; + return hit(byId[0]!); + } + if (byId.length > 1) { + // One person's DMs on more than one server. + return { error: `"${trimmed}" matches several DMs`, candidates: dmShown(byId) }; } return { error: `no registered DM matches the mention <@${id}>`, - candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6), + candidates: dmShown(dms.slice(0, 6)), }; } @@ -4088,17 +4530,17 @@ export class ChannelRegistry { return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name)); }); if (pool.length === 1) { - return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label }; + return hit(pool[0]!); } if (pool.length > 1) { return { error: `"${trimmed}" matches several DMs`, - candidates: pool.map((e) => e.descriptor.label ?? e.descriptor.id), + candidates: dmShown(pool), }; } return { error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`, - ...(dms.length ? { candidates: dms.map((e) => e.descriptor.label ?? e.descriptor.id).slice(0, 6) } : {}), + ...(dms.length ? { candidates: dmShown(dms.slice(0, 6)) } : {}), }; } @@ -4106,12 +4548,12 @@ export class ChannelRegistry { (e) => norm(e.descriptor.label ?? '') === norm(trimmed), ); if (byLabel.length === 1) { - return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label }; + return hit(byLabel[0]!); } if (byLabel.length > 1) { return { error: `label "${trimmed}" matches several channels`, - candidates: byLabel.map((e) => e.descriptor.id), + candidates: byLabel.map(token), }; } @@ -4123,12 +4565,12 @@ export class ChannelRegistry { const nameOf = normalizeChannelLabelName; const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed)); if (byName.length === 1) { - return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label }; + return hit(byName[0]!); } if (byName.length > 1) { return { error: `"${trimmed}" matches several channels`, - candidates: byName.map((e) => `${e.descriptor.label} = ${e.descriptor.id}`), + candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`), }; } @@ -4137,7 +4579,7 @@ export class ChannelRegistry { const near = entries .filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed))) .slice(0, 5) - .map((e) => `${e.descriptor.label} = ${e.descriptor.id}`); + .map((e) => `${e.descriptor.label} = ${token(e)}`); return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) }; } @@ -4148,18 +4590,19 @@ export class ChannelRegistry { * a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:` * plus body `alice …`, and `>>#fable (antra's server)` delivers * `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`, - * `#name` (label without its server suffix), the descriptor id. + * `#name` (label without its server suffix), the name that picks the + * channel alone (nameFor): its id, or, where another server registers the + * same id, its server-qualified name; and last its server-qualified name, + * for a channel whose id is no target (isProseTargetToken), as + * `skip_reply` isn't. * - * Undefined when there is no safe token: the channel isn't registered (on - * `serverId`, when given), no candidate is whitespace-free and resolves back, - * or the id is registered by more than one server. A resolved target names a - * channel by id alone, and ids are unique only within a connection, so a - * shared id could route the reply through the wrong server. + * Undefined when there is no safe token: the channel isn't registered, or + * no candidate is whitespace-free and resolves back to this channel, on + * this server. */ - proseTargetFor(channelId: string, serverId?: string): string | undefined { - const sameId = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); - const entry = serverId ? sameId.find((e) => e.serverId === serverId) : sameId[0]; - if (!entry || sameId.length > 1) return undefined; + proseTargetFor(ref: ChannelRef): string | undefined { + const entry = this.entryAt(ref); + if (!entry) return undefined; const d = entry.descriptor; const label = d.label ?? ''; const meta = d.metadata as { channelType?: string; recipientName?: string } | undefined; @@ -4169,12 +4612,13 @@ export class ChannelRegistry { const candidates = [ ...(isDm && dmName ? [`@${dmName}`] : []), ...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []), - d.id, + ...(this.nameFor(entry) !== undefined ? [this.nameFor(entry)!] : []), + qualifiedChannelName(ref), ]; for (const c of candidates) { - if (/\s/.test(c) || c === '#') continue; + if (!isProseTargetToken(c) || c === '#') continue; const r = this.resolveProseTarget(c, { forSpeech: true }); - if ('channelId' in r && r.channelId === d.id) return c; + if ('channelId' in r && sameChannel(r, ref)) return c; } return undefined; } @@ -4203,6 +4647,8 @@ export class ChannelRegistry { ): Promise<{ status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed'; channelId?: string; + /** The channel's server, beside `channelId` whenever it names one. */ + serverId?: string; label?: string; opening?: boolean; selectedForOpening?: SelectedForOpening; @@ -4227,7 +4673,7 @@ export class ChannelRegistry { } const desired = this.getDesiredState(entry.serverId, entry.descriptor.id); if (entry.open && (desired === 'open' || desired === 'tuned-out')) { - return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label }; + return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }; } opening = desired !== 'open' && desired !== 'tuned-out'; const { opened } = await this.openChannelNow(entry, 'opened-by-reply'); @@ -4239,7 +4685,10 @@ export class ChannelRegistry { channelId: entry.descriptor.id, }); } - return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label }; + return { + status: opened ? 'opened' : 'already-open', + channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label, + }; } catch (err) { // 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 @@ -4251,8 +4700,8 @@ export class ChannelRegistry { channelId: entry.descriptor.id, error: (err as Error).message, }); - const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); - return { status: 'open-failed', channelId: entry.descriptor.id, opening, + const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id }); + return { status: 'open-failed', channelId: entry.descriptor.id, serverId: entry.serverId, opening, // Where the channel is selected for opening after the failure, the // agent is told so and why, as speech's failure says. ...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' as const } : {}), @@ -4266,8 +4715,8 @@ export class ChannelRegistry { serverId?: string; backscroll?: number; beforeMessageId?: string; - }): Promise { - const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId); + }, own?: readonly ChannelRef[]): Promise { + const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own); const entry = resolved.entry; if (!entry) { return { @@ -4276,13 +4725,18 @@ export class ChannelRegistry { isError: true, }; } + // From here, the channel's own id, whichever name the call gave for it + // (a server-qualified one included). + input = { ...input, channelId: entry.descriptor.id }; + // The channel as the agent is shown it, and names it back (nameShown). + const shown = this.nameShown(refOf(entry)); // A tuned-out channel is transport-open but attention-diverted; a plain // open would silently discard the epoch (and its pending backlog dump). // Cancelling is the tune-out coordinator's flow, reached via its own // tool — refuse with the pointer rather than eat the state. if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { - return tunedOutRefusal('channel_open', input.channelId); + return this.tunedOutRefusal('channel_open', entry); } const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open'; @@ -4296,13 +4750,13 @@ export class ChannelRegistry { } catch (err) { return { success: false, - error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${(err as Error).message}`, + error: `Channel ${shown} is already open, but recording your decision to open it failed: ${(err as Error).message}`, isError: true, }; } return { success: true, - data: { channelId: input.channelId, status: 'already open' }, + data: { channelId: input.channelId, serverId: entry.serverId, status: 'already open' }, }; } @@ -4318,6 +4772,7 @@ export class ChannelRegistry { success: true, data: { channelId: input.channelId, + serverId: entry.serverId, status: alreadyDesiredOpen ? 'reconciled' : 'opened', ...(result.history ? { history: result.history } : {}), ...(result.historyTruncated ? { historyTruncated: true } : {}), @@ -4356,7 +4811,7 @@ export class ChannelRegistry { }, origin?: ChannelToolOrigin, ): Promise { - const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId); + const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined); const entry = resolved.entry; if (!entry) { return { @@ -4365,6 +4820,11 @@ export class ChannelRegistry { isError: true, }; } + // From here, the channel's own id, whichever name the call gave for it + // (a server-qualified one included). + input = { ...input, channelId: entry.descriptor.id }; + // The channel as the agent is shown it, and names it back (nameShown). + const shown = this.nameShown(refOf(entry)); // Provenance comes from trusted dispatch, not from self-description: // only module-origin calls may record a machine source, and only from @@ -4397,13 +4857,13 @@ export class ChannelRegistry { } catch (err) { return { success: false, - error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${(err as Error).message}`, + error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`, isError: true, }; } return { success: true, - data: { channelId: input.channelId, status: 'already closed' }, + data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' }, }; } @@ -4421,10 +4881,10 @@ export class ChannelRegistry { success: false, isError: false, error: - `Channel ${input.channelId} was explicitly opened (source: agent-tool); ` + + `Channel ${shown} was explicitly opened (source: agent-tool); ` + `a '${closeSource}' close does not override stated intent. Machine closes ` + `of explicit opens require an explicit idle lease (overrideExplicitOpen).`, - data: { refusal: 'explicit-open', channelId: input.channelId }, + data: { refusal: 'explicit-open', channelId: input.channelId, serverId: entry.serverId }, }; } } @@ -4437,7 +4897,7 @@ export class ChannelRegistry { // that certified an idle lease, which the guard above lets through, is // refused too, as a refusal it stands down on rather than an error. if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { - const refusal = tunedOutRefusal('channel_close', input.channelId); + const refusal = this.tunedOutRefusal('channel_close', entry); return isModuleOrigin ? { ...refusal, isError: false } : refusal; } @@ -4447,7 +4907,9 @@ export class ChannelRegistry { } catch (err) { return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true }; } - this.stopTyping(input.channelId); + // This channel's typing, not every channel with its id (stopTyping's + // fan-out of a bare name). + this.stopTyping(refOf(entry)); // Refused now, the close stays recorded for the server's next // registration, and the text says so. @@ -4461,7 +4923,7 @@ export class ChannelRegistry { if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded'); return { success: true, - data: { channelId: input.channelId, status: 'closed' }, + data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' }, }; } catch (err) { return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true }; @@ -4473,18 +4935,23 @@ export class ChannelRegistry { serverId?: string; messageId: string; acknowledge?: string; - }): Promise { - const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId); + }, own?: readonly ChannelRef[]): Promise { + const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own); const entry = resolved.entry; if (!entry) { return { success: false, error: resolved.error, isError: true }; } + // From here, the channel's own id, whichever name the call gave for it + // (a server-qualified one included). + input = { ...input, channelId: entry.descriptor.id }; + // The channel as the agent is shown it, and names it back (quotedShown). + const quoted = this.quotedShown(refOf(entry)); // A tuned-out channel's decision is the tune-out, whatever its transport, // and a decline recorded over it would end it without its cancel, so the // backlog it diverted would never reach the agent. Its cancel is what ends // it, as channel_open's refusal of a tuned-out channel says. if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { - return tunedOutRefusal('channel_decline', input.channelId); + return this.tunedOutRefusal('channel_decline', entry); } if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') { // channel_close is the call that closes it: its own answer says when, @@ -4495,7 +4962,7 @@ export class ChannelRegistry { ? this.lifecycleRefusal(entry.serverId) : undefined; return { success: false, - error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` + + error: `Channel ${quoted} is open, or set to 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, @@ -4510,7 +4977,7 @@ export class ChannelRegistry { } catch (err) { return { success: false, - error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` + + error: `Nothing changed for channel ${quoted}: ${unrecordedDecision(err).message}, so no acknowledgment ` + 'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' + 'way, ask whoever runs this host.', isError: true, @@ -4588,7 +5055,7 @@ export class ChannelRegistry { // below; the decision stands. let closeFailure: unknown; let closeFailed = false; - const current = this.channels.get(`${entry.serverId}:${input.channelId}`); + const current = this.entryAt({ serverId: entry.serverId, channelId: input.channelId }); if (current && this.directsLifecycle(entry.serverId) && this.getDesiredState(entry.serverId, input.channelId) === 'closed' && (current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) { @@ -4612,7 +5079,7 @@ export class ChannelRegistry { // asks it to open or close none of them, so a channel there is confirmed // closed only by the receipt of a request sent while the server had the // grant, whether the channel was registered or known only from a push. - const after = this.channels.get(`${entry.serverId}:${input.channelId}`); + const after = this.entryAt({ serverId: entry.serverId, channelId: input.channelId }); const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) && !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId)); // `closeError` is the result's sentence for the agent: what became of @@ -4726,6 +5193,7 @@ export class ChannelRegistry { success: true, data: { channelId: input.channelId, + serverId: entry.serverId, status, acknowledged, ...(representation ? { representation } : {}), @@ -4833,126 +5301,94 @@ export class ChannelRegistry { * inbound-only (#350's rule), not even for a publish delivered there just * before the declaration. One that started there before it goes on, since * the declaration doesn't unregister the channel: a later chunk is for a - * publish delivered before it. A bare channel id is resolved as before, to - * the one server that registers it, at each call (streamingServerFor), and - * keeps no record: a stream sent by destination ends by destination, since - * only a destination's `complete` removes its record. + * publish delivered before it. */ sendOutgoingChunk( - to: string | PublishDestination, + to: PublishDestination, conversationId: string, inferenceId: string, index: number, delta: string, ): void { - let server: McplServerConnection | null; - if (typeof to === 'string') { - server = this.streamingServerFor(to); - } else { - const key = destinationKey(to); - let started = this.takenStreams.get(inferenceId); - const continuing = started?.has(key) ?? false; - server = this.streamingServerAt(to, !continuing); - if (server && !continuing) { - if (!started) this.takenStreams.set(inferenceId, started = new Set()); - started.add(key); - } - } + const key = destinationKey(to); + let started = this.takenStreams.get(inferenceId); + const continuing = started?.has(key) ?? false; + const server = this.streamingServerAt(to, !continuing); if (!server) return; - const channelId = typeof to === 'string' ? to : to.channelId; + if (!continuing) { + if (!started) this.takenStreams.set(inferenceId, started = new Set()); + started.add(key); + } try { - server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta }); + server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: to.channelId, index, delta }); } catch { /* observer surface — never disturb the turn */ } } /** * Spec 14.3 companion: final moderated content per channel at stream end. - * Given a destination, it goes only where the stream started - * (sendOutgoingChunk), while that server still registers that channel, - * inbound-only or not, and its grant has `channels.streaming`: once per - * destination, whose consumer, such as a TTS utterance, finalizes on it - * and reconciles any chunk it missed. A bare channel id is resolved as - * before (streamingServerFor). + * It goes only where the stream started (sendOutgoingChunk), while that + * server still registers that channel, inbound-only or not, and its grant + * has `channels.streaming`: once per destination, whose consumer, such as + * a TTS utterance, finalizes on it and reconciles any chunk it missed. */ sendOutgoingComplete( - to: string | PublishDestination, + to: PublishDestination, conversationId: string, inferenceId: string, text: string, ): void { - let server: McplServerConnection | null; - if (typeof to === 'string') { - server = this.streamingServerFor(to); - } else { - const started = this.takenStreams.get(inferenceId); - if (!started?.delete(destinationKey(to))) return; - if (started.size === 0) this.takenStreams.delete(inferenceId); - server = this.streamingServerAt(to, false); - } + const started = this.takenStreams.get(inferenceId); + if (!started?.delete(destinationKey(to))) return; + if (started.size === 0) this.takenStreams.delete(inferenceId); + const server = this.streamingServerAt(to, false); if (!server) return; try { server.sendChannelsOutgoingComplete({ inferenceId, conversationId, - channelId: typeof to === 'string' ? to : to.channelId, + channelId: to.channelId, content: [{ type: 'text', text }], }); } catch { /* observer surface — never disturb the turn */ } } - private streamingServerFor(channelId: string) { - // The map is keyed `${serverId}:${channelId}`; stream targets arrive as - // bare descriptor ids (what resolveProseTarget returns). Scan like the - // resolver does, and fail closed on cross-server ambiguity — the same - // never-guess rule that governs delivery. - const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); - if (matches.length !== 1) return null; - // Nothing is published into an inbound-only channel, so nothing is - // streamed there either. - if (isInboundOnly(matches[0]!)) return null; - const server = this.serverRegistry.getServer(matches[0]!.serverId); - // §5.4: the GRANT gates streaming, not the raw advertisement. The old - // `capabilities?.channels?.streaming` check was doubly wrong: undefined - // for the boolean `channels: true` shape (masking discord-mcpl's latent - // double-post, AUDIT-001), and pre-policy it would have sent before the - // server was told anything was granted. - if (!CapabilityGrant.of(server).has('channels.streaming')) return null; - return server; - } - /** * A destination's server, for a stream message: while that server - * registers that channel, and its grant has `channels.streaming` (as in - * streamingServerFor). A stream doesn't start in an inbound-only channel. + * registers that channel, and its grant has `channels.streaming` (§5.4: + * the grant gates streaming, not the raw advertisement). A stream doesn't + * start in an inbound-only channel. */ - private streamingServerAt(to: PublishDestination, starting: boolean): McplServerConnection | null { - // The map's key joins the two ids with a colon, so another server's - // entry can sit under a destination's key: it has to be this server's. - const entry = this.channels.get(`${to.serverId}:${to.channelId}`); - if (!entry || entry.serverId !== to.serverId) return null; + private streamingServerAt(to: ChannelRef, starting: boolean): McplServerConnection | null { + const entry = this.entryAt(to); + if (!entry) return null; if (starting && isInboundOnly(entry)) return null; const server = this.serverRegistry.getServer(to.serverId); return CapabilityGrant.of(server).has('channels.streaming') ? server : null; } /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel → - * process-global default). Public so a multi-segment caller can snapshot it - * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ - resolveLocus(conversationId: string): string | null { + * process-global default), with its server. Public so a multi-segment + * caller can snapshot it ONCE and pin every segment to it via + * routeSpeech's locus. */ + resolveLocus(conversationId: string): ChannelRef | null { const home = this.homeChannelResolver?.(conversationId); - return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId); + if (home) return home; + // A turn that deliberately has no channel (null) takes no fallback. + const active = this.activeChannelResolver?.(conversationId); + return active !== undefined ? active : this.globalFallbackFor(conversationId); } /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */ - private globalFallbackFor(conversationId: string): string | null { + private globalFallbackFor(conversationId: string): ChannelRef | null { return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel; } /** * Resolve a publish target to exactly one registered channel: `serverId` - * plus `channelId` when the caller has the server, otherwise a channel id - * registered by exactly one server. A shared id is refused, never resolved - * to whichever server registered it first. + * plus `channelId` when the caller has the server, otherwise the one + * channel the id, or its server-qualified name, means (entriesNamed). A + * shared id is refused, never resolved to whichever server registered it + * first. */ resolveDestination( target: { serverId?: string; channelId: string }, @@ -4979,9 +5415,11 @@ export class ChannelRegistry { if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) { return { error: 'serverId must name a server (omit it to resolve the channel id alone)' }; } - const matches = [...this.channels.values()].filter( - (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId), - ); + // Without a server, the channel id, or its server-qualified name, means + // what it means to every channel tool (entriesNamed). + const matches = target.serverId + ? [this.entryAt({ serverId: target.serverId, channelId: target.channelId })].filter((e): e is ChannelEntry => e !== undefined) + : this.entriesNamed(target.channelId); if (matches.length === 0) { return { error: target.serverId @@ -4990,12 +5428,16 @@ export class ChannelRegistry { }; } if (matches.length > 1) { + // The framework's own speech always names its server, so only a + // caller that named none meets this, and it can name one. + // Each named by what picks it alone, or by its id and server where + // nothing does; "more than one server" only where the servers differ. + const names = matches.map((e) => this.quotedShown(refOf(e))); + const servers = new Set(matches.map((e) => e.serverId)); return { - // Speech names no server, and neither its >> prefix nor - // channel_publish can: whoever runs this host can. A caller that - // can name one reads that none was. - error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` + - 'if you need them there, ask whoever runs this host', + error: servers.size > 1 + ? `channel id "${target.channelId}" is on more than one server (${names.join(', ')}), and the destination named no server` + : `channel id "${target.channelId}" names more than one channel on server "${matches[0]!.serverId}" (${names.join(', ')})`, }; } return { entry: matches[0]! }; @@ -5010,9 +5452,9 @@ export class ChannelRegistry { * publishReceiptOutcome and publishErrorOutcome. * * Resolution is exact (resolveDestination): `serverId` plus `channelId` - * when the caller has the server, otherwise the channel id must be - * registered by exactly one server (a shared id is refused, never routed - * through whichever server came first). An omitted `serverId` means "not + * when the caller has the server, otherwise the channel id, or its + * server-qualified name, must mean exactly one channel (a shared id is + * 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 @@ -5165,7 +5607,7 @@ export class ChannelRegistry { // again on the registration that stands. waitForOpenChannel returns // only a registration it found open and whose close doesn't stand, or // throws, so the loop ends unless the channel keeps changing. - const key = `${entry.serverId}:${entry.descriptor.id}`; + 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))) { try { @@ -5214,7 +5656,7 @@ export class ChannelRegistry { async deliverSpeech( conversationId: string, text: string, - /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus + /** The turn's FROZEN locus, with its server, snapshotted once by the caller (resolveLocus * at turn start) and passed to every segment of the turn. Mandatory: * there is deliberately no live-resolution fallback here — a late * dispatch resolving live can read the NEXT turn's trigger state or a @@ -5223,7 +5665,7 @@ export class ChannelRegistry { * "this turn is pinned to no locus": fail loudly rather than guess, * except for an ephemeral run, whose speech is then its caller's * result. */ - locusChannelId: string | null, + locus: ChannelRef | null, /** `heldOnFailure`: the caller holds a `failed` delivery's words as a * draft with its own notice, so no failure marker is written for it. * An `unknown` outcome still gets its marker. */ @@ -5231,14 +5673,16 @@ export class ChannelRegistry { ): Promise { // `logged` is what the log line and the trace say, where it isn't the // agent's reason (the two no-locus reasons below). - const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => { + const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; + const channelId = channel?.channelId ?? null; const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined; const detailText = detail !== undefined ? `; connector detail: ${detail}` : ''; console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, + ...(channel ? { serverId: channel.serverId } : {}), channelId: channelId ?? '', reason: logged, textLen: text.length, @@ -5246,7 +5690,10 @@ export class ChannelRegistry { ...(detail !== undefined ? { detail } : {}), }); if (status === 'unknown' || !opts?.heldOnFailure) { - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) }); + this.onRouteFailure?.({ + conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}), + reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}), + }); } return outcome; }; @@ -5260,12 +5707,12 @@ export class ChannelRegistry { // Runtime backstop for JS callers the compiler can't see: an omitted // locus is a routing bug at the call site, never something to paper over // with a live re-resolution. - if (locusChannelId === undefined) { + if (locus === undefined) { const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host'; return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)'); } - if (!locusChannelId) { + if (!locus) { // An ephemeral run with no channel of its own: its plain speech is its // caller's result (takesGlobalFallback). It returns as not delivered, // with its own reason, and writes no line, trace or notice. @@ -5282,10 +5729,10 @@ export class ChannelRegistry { 'turn has no locus (no home/trigger channel; nothing to deliver into)'); } - const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true }); - const channelId = outcome.destination?.channelId ?? locusChannelId; + const outcome = await this.publish(conversationId, text, { serverId: locus.serverId, channelId: locus.channelId, routed: true }); + const channelId = outcome.destination?.channelId ?? locus.channelId; if (outcome.status !== 'delivered') { - return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome); + return fail(outcome.destination ?? locus, outcome.reason ?? 'delivery not confirmed', outcome); } console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination!.serverId}, delivered=true)`); @@ -5309,9 +5756,9 @@ export class ChannelRegistry { async routeSpeech( conversationId: string, text: string, - locusChannelId: string | null, + locus: ChannelRef | null, ): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> { - const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); + const outcome = await this.deliverSpeech(conversationId, text, locus); if (outcome.status !== 'delivered') return null; return { delivered: true, @@ -5328,12 +5775,15 @@ export class ChannelRegistry { * given speak_in_channel for the tuned-out channels whose messages it reads. */ private async handleToolPublish( - input: { channelId?: string; content?: string; text?: string }, + /** `serverId` isn't in channel_publish's schema: the framework gives it + * with a conversation fork's home, and publishForAgent with a ref. */ + input: { channelId?: string; serverId?: string; content?: string; text?: string }, origin?: ChannelToolOrigin, - /** Filled for publishForAgent: the publish's outcome once a request is - * attempted, for its trace, or which check of the destination refused - * it before anything went out, for its answer. */ - seen?: { outcome?: PublishOutcome; refusal?: PublishRefusalReason }, + /** Filled for publishForAgent: the channel the call names once it + * resolves, and the publish's outcome once a request is attempted, for + * its trace, or which check of the destination refused it before + * anything went out, for its answer. */ + seen?: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason }, tool: 'channel_publish' | 'speak_in_channel' = 'channel_publish', ): Promise { const findIds = tool === 'channel_publish' @@ -5358,9 +5808,13 @@ export class ChannelRegistry { // marker, apart from an edit the coalescer delivers on acceptance as the // agent's first view. An ephemeral run never takes that default // (takesGlobalFallback), so it names one. + // A channel named by a string means what it means to every channel tool + // (entriesNamed): one channel, by its id or its server-qualified name, + // and a name two servers' channels answer to is refused with them as + // candidates, never sent to the first. const caller = origin?.kind === 'agent' ? origin.agentName : undefined; - const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel); - if (!channelId) { + const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel; + if (!(input.channelId ?? fallback)) { return { success: false, error: tool === 'speak_in_channel' @@ -5372,17 +5826,80 @@ export class ChannelRegistry { isError: true, }; } - - const entry = this.findChannelEntry(channelId); + // A host's ref (publishForAgent, the one caller that gives + // speak_in_channel a serverId) names its exact pair, an empty serverId + // included, as publish's destination does. A tool's empty serverId is a + // parameter filled with nothing, read as none given, as the channel tools + // read one. + const serverGiven = tool === 'speak_in_channel' ? input.serverId !== undefined : !!input.serverId; + // Inside the calling turn, a name one of the turn's own channels answers + // to by its id means that channel, as a `>>` target does (ownChannelNamed). + const own = input.channelId !== undefined && !serverGiven && origin?.kind === 'agent' + ? this.ownChannelNamed(input.channelId, origin.own) + : undefined; + let entry: ChannelEntry | undefined; + if (input.channelId === undefined) { + // A serverId given with no channelId names the default's id on that + // server, held to as one given with a channelId is. + entry = this.entryAt(serverGiven ? { serverId: input.serverId!, channelId: fallback!.channelId } : fallback!); + } else if (own && 'neither' in own) { + if (seen) seen.refusal = 'shared-id'; + return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true }; + } else if (own) { + entry = this.entryAt(own.channel); + if (!entry) { + if (seen) seen.refusal = 'channel-not-registered'; + return { success: false, error: this.ownChannelGoneRefusal(input.channelId, own.channel, 'publish'), isError: true }; + } + } else if (serverGiven) { + entry = this.entryAt({ serverId: input.serverId!, channelId: input.channelId }); + } else { + const matches = this.entriesNamed(input.channelId); + if (matches.length > 1) { + if (seen) seen.refusal = 'shared-id'; + return { + success: false, + error: this.sharedNameRefusal(input.channelId, matches, { takesServerId: false, nothing: 'nothing was posted' }), + isError: true, + }; + } + entry = matches[0]; + } if (!entry) { if (seen) seen.refusal = 'channel-not-registered'; - return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true }; + return { + success: false, + // The default channel is gone (its server no longer registers it), + // though another server's channel may have its id. + // No advice: the default's channel is gone, and another server's + // channel with its id isn't where its message came from. + error: input.channelId === undefined + ? serverGiven + ? `Server "${input.serverId}" has no channel "${fallback!.channelId}", the id of the channel this call would default to, so nothing was posted.` + : `The channel this would default to, ${this.quotedShown(fallback!)}, isn't registered now, so nothing was posted.` + // This tool's schema has no serverId, so one given here is a host's + // ref (publishForAgent), or one an agent added unasked, and the + // refusal offers names its channelId takes instead. (A fork's guard + // refuses a home its server no longer registers before this runs.) + : serverGiven + ? `No channel "${input.channelId}" is on server "${input.serverId}", so nothing was posted. ${this.elsewhereText(input.channelId, false)}` + : `No channel has the id "${input.channelId}", so nothing was posted. ${findIds}`, + isError: true, + }; } + // From here, the channel's own id, whichever name the call gave for it, + // and the name the agent is shown it by (nameShown). + const channelId = entry.descriptor.id; + // The channel the call names, for a caller that reports a refusal before + // the send (publishForAgent's trace). + if (seen) seen.resolved = refOf(entry); + const shown = this.nameShown(refOf(entry)); + const quoted = this.quotedShown(refOf(entry)); if (isInboundOnly(entry)) { if (seen) seen.refusal = 'inbound-only'; return { success: false, - error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`, + error: `Channel ${shown} is ${inboundOnlyReason(entry)}`, isError: true, }; } @@ -5395,7 +5912,7 @@ export class ChannelRegistry { const reach = this.serverReach(entry.serverId); seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`; } - return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true }; + return { success: false, error: `Nothing was posted to channel ${quoted}: ${this.sendRefusal(entry.serverId)}.`, isError: true }; } const publishParams: ChannelsPublishParams = { conversationId: '', // Framework will fill this when wired @@ -5425,11 +5942,15 @@ export class ChannelRegistry { if (outcome.status === 'delivered') return { success: true, data: result }; // The connector's data may say what a multi-part send already posted. const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : ''; + // Named as it's shown now, after the send's await: registrations may have + // changed meanwhile, and the name shown before it may now pick another + // channel. + const sentTo = this.quotedShown(refOf(entry)); return { success: false, error: (outcome.status === 'failed' - ? `Nothing was posted to channel "${channelId}": ${outcome.reason}.` - : `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` + + ? `Nothing was posted to channel ${sentTo}: ${outcome.reason}.` + : `Posting to channel ${sentTo} wasn't confirmed: ${outcome.reason}. ` + 'It may or may not have been posted, so sending it again could post it twice.') + detail, isError: true, }; diff --git a/src/mcpl/conversation-router.ts b/src/mcpl/conversation-router.ts index b2d00b8d1..f536d4deb 100644 --- a/src/mcpl/conversation-router.ts +++ b/src/mcpl/conversation-router.ts @@ -37,6 +37,7 @@ import { createHash } from 'node:crypto'; import type { ContextStrategy } from '@animalabs/context-manager'; import type { ChannelDescriptor } from './types.js'; +import { destinationKey, type ChannelRef } from './channel-registry.js'; // ============================================================================ // Config & types @@ -108,6 +109,9 @@ export interface ConversationRouterConfig { export interface ConversationBinding { channelId: string; + /** The channel's server: channel ids are unique only within one server's + * connection (MCPL §14.2), so each server's channel has its own binding. */ + serverId: string; agentName: string; /** Engagement counter per channel — fresh forks get a new generation. */ generation: number; @@ -157,11 +161,15 @@ export const DEFAULT_CLOSURE_PROMPT = export class ConversationRouter { private config: ConversationRouterConfig; - /** Active bindings, keyed by channelId. */ + /** Active bindings, keyed by the channel's server and id (destinationKey): + * two servers' channels with one id are two conversations. */ private bindings = new Map(); - /** Engagement counter per channel — survives unbind, so a re-bound channel - * gets a distinct fork agent name. */ + /** Engagement counter per channel id — survives unbind, so a re-bound + * channel gets a distinct fork agent name. Kept by bare id, as it's + * persisted: two servers sharing an id share a counter, which still never + * repeats a name, and their forks' names differ anyway, since each hashes + * its server (forkAgentName). */ private generations = new Map(); constructor(config: ConversationRouterConfig) { @@ -191,7 +199,7 @@ export class ConversationRouter { * unchanged; a failed spawn doesn't leave a dangling route. */ route(facts: IncomingMessageFacts): RoutingDecision { - const existing = this.bindings.get(facts.channelId); + const existing = this.bindings.get(bindingKey({ serverId: facts.serverId ?? '', channelId: facts.channelId })); if (existing) { return { @@ -223,39 +231,44 @@ export class ConversationRouter { * Record a binding after the framework successfully spawned the fork. * Returns the new binding. */ - bind(channelId: string, agentName: string, generation: number, now = Date.now()): ConversationBinding { - this.generations.set(channelId, generation); + bind(channel: ChannelRef, agentName: string, generation: number, now = Date.now()): ConversationBinding { + // The counter is kept by bare id, and two servers' spawns on one id run + // beside each other, so a bind can finish after a later spawn's: the + // counter keeps the higher, or the next spawn would propose a fork name + // already used. + this.generations.set(channel.channelId, Math.max(this.generations.get(channel.channelId) ?? 0, generation)); const binding: ConversationBinding = { - channelId, + channelId: channel.channelId, + serverId: channel.serverId, agentName, generation, boundAt: now, lastActivity: now, }; - this.bindings.set(channelId, binding); + this.bindings.set(bindingKey(channel), binding); return binding; } /** Record delivered activity on an existing binding. Does not bind a * channel or advance its generation. Call after successful delivery, not * when previewing a route. The optional clock supports deterministic use. */ - touch(channelId: string, now = Date.now()): void { - const binding = this.bindings.get(channelId); + touch(channel: ChannelRef, now = Date.now()): void { + const binding = this.bindings.get(bindingKey(channel)); if (binding) binding.lastActivity = now; } - unbind(channelId: string): void { - this.bindings.delete(channelId); + unbind(channel: ChannelRef): void { + this.bindings.delete(bindingKey(channel)); } - getBinding(channelId: string): ConversationBinding | undefined { - return this.bindings.get(channelId); + getBinding(channel: ChannelRef): ConversationBinding | undefined { + return this.bindings.get(bindingKey(channel)); } /** Reverse lookup: the channel an agent is bound to, if any. */ - channelForAgent(agentName: string): string | undefined { + channelForAgent(agentName: string): ChannelRef | undefined { for (const binding of this.bindings.values()) { - if (binding.agentName === agentName) return binding.channelId; + if (binding.agentName === agentName) return { serverId: binding.serverId, channelId: binding.channelId }; } return undefined; } @@ -325,6 +338,11 @@ export class ConversationRouter { /** The readable part of a fork's name stops here: uniqueness rides on the hash. */ const FORK_NAME_READABLE_MAX = 64; +/** A binding's key: its channel's server and id (destinationKey). */ +function bindingKey(channel: ChannelRef): string { + return destinationKey(channel); +} + /** * A fork's agent name: `--g-`, such as * `conversation-C123-g2-e08e564271867d16` for Slack's channel `C123` on the diff --git a/src/mcpl/hook-orchestrator.ts b/src/mcpl/hook-orchestrator.ts index 3d2656c30..785f4280e 100644 --- a/src/mcpl/hook-orchestrator.ts +++ b/src/mcpl/hook-orchestrator.ts @@ -114,8 +114,17 @@ export class HookOrchestrator { * * Returns aggregated ContextInjection[] ready for context-manager's compile(). * Fail-open: servers that time out or error are silently skipped. + * + * `channelsFor` gives each server its own channel context (§14.4), in + * place of `params.channels`: channel ids are unique only within one + * server's connection (§14.2), so each server is told only of its own + * channels (ChannelRegistry.buildChannelContext). Undefined from it leaves + * the field out for that server. */ - async beforeInference(params: BeforeInferenceParams): Promise { + async beforeInference( + params: BeforeInferenceParams, + channelsFor?: (serverId: string) => BeforeInferenceParams['channels'], + ): Promise { // §5.4: the GRANT decides fan-out, not the raw advertisement — which // also fixes selection for the recursive §5.1 shape, where // `beforeInference: {observe: true, …}` is an object, not `true`. A @@ -132,7 +141,7 @@ export class HookOrchestrator { this._isInHook = true; try { - return await this.fanOutBeforeInference(servers, params); + return await this.fanOutBeforeInference(servers, params, channelsFor); } finally { this._isInHook = false; } @@ -163,17 +172,33 @@ export class HookOrchestrator { private async fanOutBeforeInference( servers: McplServerConnection[], params: BeforeInferenceParams, + channelsFor?: (serverId: string) => BeforeInferenceParams['channels'], ): Promise { const results = await Promise.allSettled( - servers.map((server) => { + // Async, so a throw building one server's channel context fails that + // server alone, as its timeout or transport error would. + servers.map(async (server) => { + let base = params; + if (channelsFor) { + const { channels: _shared, ...rest } = params; + let channels: BeforeInferenceParams['channels']; + try { + channels = channelsFor(server.id); + } catch (err) { + // A bug, not a server's failure: said, then this server is skipped. + console.error(`[beforeInference] the channel context for "${server.id}" couldn't be built, so it isn't called:`, err); + throw err; + } + base = channels ? { ...rest, channels } : rest; + } // §10.1: a server not granted observe MUST receive // `userMessage: null` — the field is ABSENT authority, not merely // discouraged — while the hook is still invoked so granted // injection positions keep working (write-without-read). const perServer: BeforeInferenceParams = CapabilityGrant.of(server).has('contextHooks.beforeInference.observe') - ? params - : { ...params, userMessage: null }; + ? base + : { ...base, userMessage: null }; return withTimeout( server.sendBeforeInference(perServer), BEFORE_INFERENCE_TIMEOUT_MS, diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index 7747cc5f7..3f1c83b12 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -171,7 +171,7 @@ export { InferenceRouter } from './inference-router.js'; // Channel registry (channel lifecycle, incoming messages, synthesized tools) export { ChannelRegistry } from './channel-registry.js'; -export type { PublishRefusalReason } from './channel-registry.js'; +export type { ChannelRef, PublishRefusalReason } from './channel-registry.js'; // Inbound source envelope: where each accepted item came from, stamped once; // and the digest of a delivered or stored body diff --git a/src/mcpl/prose-grammar.ts b/src/mcpl/prose-grammar.ts index 9bac23ef3..51f3feb24 100644 --- a/src/mcpl/prose-grammar.ts +++ b/src/mcpl/prose-grammar.ts @@ -19,6 +19,13 @@ export interface ProsePrefix { body: string; } +/** Whether a `>>` (or `>>>`) prefix line reads `name` as a target: one token, + * and not `skip_reply`, which the line reads as private speech. For a name + * offered to an agent to write after `>>`. */ +export function isProseTargetToken(name: string): boolean { + return /^\S+$/.test(name) && name !== 'skip_reply'; +} + /** A REAL prefix line: `>>` immediately followed by a target token. */ export const PROSE_PREFIX_LINE = /^>>\S/; diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index 5e2de08e9..a46b0c3a8 100644 --- a/src/tune-out/coordinator.ts +++ b/src/tune-out/coordinator.ts @@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto'; import type { ContentBlock } from '@animalabs/membrane'; import type { StoredMessage } from '@animalabs/context-manager'; -import type { ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js'; +import type { ChannelRef, ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js'; import type { McplServerRegistry } from '../mcpl/server-registry.js'; import { CapabilityGrant } from '../mcpl/capability-grant.js'; import { armAfter, armAt } from '../deadline-timer.js'; @@ -45,7 +45,7 @@ const CUT_MARK = " [cut to fit; the whole message is in the channel's history]"; * its transport is now where the repeat tried to open it again (`transport`, * from the framework's dispatch), then how to change the settings. */ -export function alreadyTunedOutText(channelId: string, p: TuneOutParams, transport?: string): string { +export function alreadyTunedOutText(shown: string, p: TuneOutParams, transport?: string): string { // A duration so long its end is past what a Date can hold has no time to show. const expires = p.expiresAtMs !== undefined ? new Date(p.expiresAtMs) : undefined; const until = expires && Number.isFinite(expires.getTime()) @@ -53,7 +53,7 @@ export function alreadyTunedOutText(channelId: string, p: TuneOutParams, transpo : ''; // A thrown error's words may end without a stop. const now = transport === undefined ? '' : /[.!?]$/.test(transport) ? `${transport} ` : `${transport}. `; - return `Nothing changed${transport !== undefined ? ' in the tune-out' : ''}: ${JSON.stringify(channelId)} is already ` + return `Nothing changed${transport !== undefined ? ' in the tune-out' : ''}: ${JSON.stringify(shown)} is already ` + `tuned out, with cadenceSeconds ${p.cadenceSeconds}, backlogCap ${p.backlogCap} and maxWakes ${p.maxWakes}${until}. ` + now + 'To change those, cancel it first with tune_out mode "cancel", which delivers its backlog to you now, then call ' @@ -113,7 +113,7 @@ export interface TuneOutFrameworkHooks { /** Move the subconscious window anchor (never forward past active epochs). */ setSubconsciousAnchor: (sequence: number) => void; /** True when a channel is bound to a conversation-fork agent. */ - isForkBound: (channelId: string) => boolean; + isForkBound: (channel: ChannelRef) => boolean; /** Gate privileged-users list (#77: privileged authors wake, like mentions). */ isPrivilegedAuthor: (authorId: string | null | undefined) => boolean; /** speak_in_channel master switch (recipe-side, canary-gated). */ @@ -144,6 +144,15 @@ export class TuneOutCoordinator { return `${serverId}\0${channelId}`; } + /** A channel as the resident and the subconscious are shown it + * (ChannelRegistry.nameShown): its id, or, where another server registers + * the same id, its server-qualified name, which tune_out and + * cancel_tuneout take back; or, for a channel no string picks alone, its + * id and its server. */ + private shown(serverId: string, channelId: string): string { + return this.channelRegistry.nameShown({ serverId, channelId }); + } + /** Restart cadence + expiry timers and re-anchor after a process restart. */ resumeActiveEpochs(): void { let oldestAnchor: number | null = null; @@ -232,17 +241,17 @@ export class TuneOutCoordinator { + 'whoever runs this host.', }; } - if (this.hooks.isForkBound(channelId)) { + if (this.hooks.isForkBound({ serverId, channelId })) { return { ok: false, - error: `Nothing changed: a conversation is bound to ${JSON.stringify(channelId)}, so its messages go to that ` + error: `Nothing changed: a conversation is bound to ${JSON.stringify(this.shown(serverId, channelId))}, so its messages go to that ` + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change " + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune ' + 'out a different channel, or ask whoever runs this host if this one should be diverted.', }; } const current = this.channelRegistry.getTuneOutState(serverId, channelId); - if (current) return { ok: false, error: alreadyTunedOutText(channelId, current.params), alreadyTunedOut: true }; + if (current) return { ok: false, error: alreadyTunedOutText(this.shown(serverId, channelId), current.params), alreadyTunedOut: true }; // A tool's input reaches here as the model wrote it, and the store can't // keep a NaN or an Infinity: its append would throw after the registry // had marked the channel tuned out. So each option is read as a number @@ -328,7 +337,7 @@ export class TuneOutCoordinator { const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source); if (!ended) { // Its reader is the resident's tune_out: cancel_tuneout finds the channel first. - return { ok: false, error: this.notTunedOut(channelId, 'you') }; + return { ok: false, error: this.notTunedOut(this.shown(serverId, channelId), 'you') }; } this.clearTimers(serverId, channelId); this.divertedSinceInvocation.delete(this.key(serverId, channelId)); @@ -359,10 +368,12 @@ export class TuneOutCoordinator { .join('\n'); return `${authorOf(m)}: ${text}`; }; - const lifecycle = `[Tune-out: ${channelId} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`; + // The channel as the resident and the subconscious are shown it. + const name = this.shown(serverId, channelId); + const lifecycle = `[Tune-out: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`; const render = (lines: string[], truncated: number): string => { const attrs = [ - `channel="${channelId}"`, + `channel="${name}"`, `messages=${backlog.length}`, ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []), ]; @@ -441,7 +452,9 @@ export class TuneOutCoordinator { this.hooks.addMessage( subName, [{ type: 'text', text: subconsciousNote }], - { kind: 'subconscious-note', channelId, epochId }, + // With its server: a conversational row whose channel the framework + // reads (repinChannel), where another server's channel can share the id. + { kind: 'subconscious-note', channelId, serverId, epochId }, ); } const primary = this.hooks.primaryName(); @@ -467,7 +480,7 @@ export class TuneOutCoordinator { : ''; this.hooks.addMessage( 'user', - [{ type: 'text', text: `[Tune-out cancelled: ${channelId} — ${reason}.${truncNote}${heldNote}]` }], + [{ type: 'text', text: `[Tune-out cancelled: ${this.shown(serverId, channelId)} — ${reason}.${truncNote}${heldNote}]` }], { system: true, kind: 'tune-out-cancel-report', channelId }, subName, ); @@ -481,7 +494,7 @@ export class TuneOutCoordinator { subName, ); } - this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out'); } this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount }); return { ok: true }; @@ -649,16 +662,17 @@ export class TuneOutCoordinator { if (!subName) return; const key = this.key(serverId, channelId); + const shown = this.shown(serverId, channelId); const notice = trigger === 'wake' - ? `[Tune-out wake: ${channelId} — ${count} addressed message${count === 1 ? '' : 's'}]` - : `[Tune-out cadence: ${channelId} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`; + ? `[Tune-out wake: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]` + : `[Tune-out cadence: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`; this.hooks.addMessage( 'user', [{ type: 'text', text: notice }], { system: true, kind: `tune-out-${trigger}`, channelId }, subName, ); - this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out ${trigger}: ${shown}`, 'tune-out'); // Counted from here only once the turn has been asked for, so a turn that // throws leaves its messages for the next period's turn. this.divertedSinceInvocation.set(key, 0); @@ -740,6 +754,8 @@ export class TuneOutCoordinator { async handleSubconsciousTool( name: string, input: Record, + /** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */ + own: readonly ChannelRef[] = [], ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { switch (name) { case 'deliver_summary': { @@ -755,19 +771,48 @@ export class TuneOutCoordinator { } const subName = this.hooks.subconsciousName(); if (!subName) return { success: false, error: 'subconscious not configured', isError: true }; - // Verbatim, under its own participant name, into the resident's window. + // Verbatim, under its own participant name, into the resident's window, + // tagged with the channel the name means and its server, since the + // framework reads a conversational row's channel (repinChannel). The + // name means what it means to every channel tool: inside the calling + // turn, the id of one of the turn's own channels means that channel, + // refused where its server no longer registers it or the turn has + // channels with that id on two servers (ownChannelFor); otherwise a + // name several registered channels answer to is refused with them + // (sharedNameRefusalFor). So no row is left for a re-pin to read as + // another server's channel. A name no registered channel answers to + // is kept as given, with no server, which a re-pin reads by name when + // it weighs the row, as it reads any message without its server. + const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver'); + if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; + const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(channelId, 'nothing was delivered'); + if (shared) return { success: false, error: shared, isError: true }; + const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId); this.hooks.addMessage(subName, [{ type: 'text', text }], { kind: 'subconscious-summary', - channelId, + ...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }), }); return { success: true, data: { delivered: true } }; } case 'cancel_tuneout': { - const channelId = String(input.channelId ?? ''); - const located = this.findByChannel(channelId); - if (!located) return { success: false, error: this.notTunedOut(channelId, 'the resident'), isError: true }; + const name = String(input.channelId ?? ''); + // The name means what it means to every channel tool, the + // resident's tune_out included: inside the calling turn, the id of + // one of the turn's own channels means that channel (ownChannelFor); + // otherwise a name several registered channels answer to is refused + // (channelsNamed), whichever of them is tuned out, never taken to be + // the tuned-out one. + const mine = this.channelRegistry.ownChannelFor(name, own); + if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; + const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(name); + const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name); + if (shared) return { success: false, error: shared, isError: true }; + const channel = named[0]; + if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) { + return { success: false, error: this.notTunedOut(name, 'the resident'), isError: true }; + } const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined; - const result = this.cancel(located.serverId, channelId, 'subconscious', 'subconscious judgment', note); + const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note); return result.ok ? { success: true, data: { cancelled: true } } : { success: false, error: result.error, isError: true }; @@ -817,7 +862,11 @@ export class TuneOutCoordinator { if (!text) { return { success: false, error: 'Nothing was posted: speak_in_channel needs the message in "text".', isError: true }; } - return this.channelRegistry.publishForAgent(channelId, text, this.hooks.subconsciousName() ?? 'Subconscious'); + // Inside the calling turn, the id of one of the turn's own channels + // means that channel, as for every channel tool (ownChannelFor). + const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish'); + if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; + return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? 'Subconscious'); } default: return { success: false, error: `unknown subconscious tool: ${name}`, isError: true }; @@ -825,32 +874,26 @@ export class TuneOutCoordinator { } /** - * A cancel of a channel that isn't tuned out: which channels are, so the - * next call can name one, and how a tune-out ends without one. `backlogTo` + * A cancel of a channel that isn't tuned out, by the name the caller gave + * or knows it by: which channels are, so the next call can name one, and + * how a tune-out ends without one. `backlogTo` * names the resident as the message's reader knows it: "you" to the * resident, "the resident" to the subconscious. It speaks of connected * channels because listActive reads the registered ones: removing a server * drops its channels and keeps their tune-outs, so a tune-out there is in - * neither this list nor findByChannel's lookup, and the text claims nothing - * about it. + * neither this list nor the name's lookup (channelsNamed, which reads the + * registered channels too), and the text claims nothing about it. */ - private notTunedOut(channelId: string, backlogTo: 'you' | 'the resident'): string { - const active = this.listActive().map((e) => JSON.stringify(e.channelId)); + private notTunedOut(name: string, backlogTo: 'you' | 'the resident'): string { + const active = this.listActive().map((e) => JSON.stringify(this.shown(e.serverId, e.channelId))); const opening = active.length === 0 ? 'no connected channel is tuned out now.' - : `no connected channel ${JSON.stringify(channelId)} is tuned out. Connected channels tuned out now: ` + : `no connected channel ${JSON.stringify(name)} is tuned out. Connected channels tuned out now: ` + `${active.join(', ')}.`; return `Nothing was cancelled: ${opening} A tune-out also ends by itself when its duration runs out, or at the ` + `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`; } - private findByChannel(channelId: string): { serverId: string } | null { - for (const entry of this.listActive()) { - if (entry.channelId === channelId) return { serverId: entry.serverId }; - } - return null; - } - /** Stop all timers (framework shutdown). */ stop(): void { for (const timer of this.cadenceTimers.values()) clearTimeout(timer); diff --git a/src/types/agent.ts b/src/types/agent.ts index 655911693..248717e6d 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -418,6 +418,17 @@ export interface InferenceRequest { * ephemeral run's (a subagent's), which takes no global fallback. */ channelId?: string; + /** + * The server of `channelId`'s channel. MCPL requires a channel id to be + * unique only within one server's connection (§14.2), so where two + * servers register the same id only the pair names one channel. The + * framework's own wakes set it beside `channelId`. A host's request that + * gives `channelId` alone names the one channel that id, or its + * server-qualified name (`serverId:channelId`), means; one that two + * servers' channels answer to names none, and the turn's speech has no + * channel of its own. + */ + channelServerId?: string; /** * True when the triggering message explicitly addressed the agent (mention, * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake diff --git a/src/types/trace.ts b/src/types/trace.ts index 8b46876fb..622014449 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -66,6 +66,14 @@ export type TraceEvent = * without re-deriving routing. */ channelId?: string; + /** + * The server that registers `channelId`, given with it. Two MCPL + * servers may register one channel id, so a tap that posts back to the + * turn's channel, such as a host telling it the turn failed, passes this + * server with the id. The streaming traces, `inference:tokens` and + * `inference:content_block`, carry the id alone. + */ + serverId?: string; /** True for a control-plane wake that must not surface typing/prose. */ silent?: boolean; }) @@ -146,7 +154,9 @@ export type TraceEvent = * content in it or its retry after a refusal, and a tool result the * boundary reports carries the channel of the round it answers. In * hybrid mode a `>>>` envelope can send the text itself elsewhere. - * Explicit mode: the channel the start's wake came from. */ + * Explicit mode: the channel its typing shows, where the start's wake + * came from, or, at a start whose wake names none, such as a budget + * restart, the one the turn already shows. */ channelId?: string; }) | (TraceEventBase & { @@ -169,7 +179,9 @@ export type TraceEvent = * content in it or its retry after a refusal, and a tool result the * boundary reports carries the channel of the round it answers. In * hybrid mode a `>>>` envelope can send the text itself elsewhere. - * Explicit mode: the channel the start's wake came from. */ + * Explicit mode: the channel its typing shows, where the start's wake + * came from, or, at a start whose wake names none, such as a budget + * restart, the one the turn already shows. */ channelId?: string; }) | (TraceEventBase & { @@ -508,6 +520,8 @@ export type TraceEvent = | (TraceEventBase & { type: 'mcpl:conversation-spawned'; channelId: string; + /** The channel's server: a channel id is unique only within one server. */ + serverId: string; agentName: string; generation: number; template: string; @@ -515,22 +529,30 @@ export type TraceEvent = | (TraceEventBase & { type: 'mcpl:conversation-spawn-failed'; channelId: string; + /** The channel's server: a channel id is unique only within one server. */ + serverId: string; agentName: string; error: string; }) | (TraceEventBase & { type: 'mcpl:conversation-unrouted'; channelId: string; + /** The channel's server: a channel id is unique only within one server. */ + serverId: string; messageId: string; }) | (TraceEventBase & { type: 'mcpl:conversation-binding-orphaned'; channelId: string; + /** The channel's server: a channel id is unique only within one server. */ + serverId: string; agentName: string; }) | (TraceEventBase & { type: 'mcpl:conversation-closed'; channelId: string; + /** The channel's server: a channel id is unique only within one server. */ + serverId: string; agentName: string; reason: 'idle-ttl'; }) @@ -538,6 +560,8 @@ export type TraceEvent = type: 'mcpl:conversation-disposed'; agentName: string; channelId?: string; + /** The home channel's server, beside its channelId. */ + serverId?: string; }) // Host-owned speech routing (ChannelRegistry.routeSpeech). One trace per @@ -569,6 +593,9 @@ export type TraceEvent = type: 'mcpl:speech-route-failed'; /** Agent / conversation whose speech failed to route. */ conversationId: string; + /** The channel's server, where the destination, or the caller, named + * one: a channel id is unique only within one server. */ + serverId?: string; /** Empty string when the failure was "no locus at all". */ channelId: string; reason: string; diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index b796e17eb..968b571e5 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -267,8 +267,8 @@ function captureRouting(framework: AgentFramework) { const routed: string[] = []; const registry: Record = { handleChannelToolCall: async () => ({ success: true, data: 'ok' }), - routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => null, + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:commons' }; }, + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:commons' }), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { @@ -684,10 +684,10 @@ test('disposed conversation fork cannot regain legacy tool permissions', async ( const h = await harness({ allowedTools: ['probe--allowed'] }); try { const internals = h.framework as unknown as { - createConversationAgent(name: string, channel: string): Promise<{name: string}>; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise<{name: string}>; disposeConversationAgent(name: string): void; }; - const fork = await internals.createConversationAgent('conversation-probe-g1', 'world:probe'); + const fork = await internals.createConversationAgent('conversation-probe-g1', { serverId: 'stub', channelId: 'world:probe' }); assert.ok(h.framework.getAgent(fork.name)); const call = {id: 'fork-permitted', name: 'probe--allowed', input: {}, callerAgentName: fork.name}; assert.equal((await h.framework.executeToolCall(call)).success, true); @@ -894,8 +894,8 @@ test('ephemeral and conversation fork first surfaces warn once per Agent instanc if (!line.startsWith('[deprecated]')) warnings.push(line); }; try { - const internals = h.framework as unknown as { createConversationAgent(name: string, channel: string): Promise<{name: string}>; disposeConversationAgent(name: string): void }; - const fork = await internals.createConversationAgent('utility-fork-g1', 'world:probe'); + const internals = h.framework as unknown as { createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise<{name: string}>; disposeConversationAgent(name: string): void }; + const fork = await internals.createConversationAgent('utility-fork-g1', { serverId: 'stub', channelId: 'world:probe' }); const created = await h.framework.createEphemeralAgent({ name: 'utility-worker', model: 'test', systemPrompt: 'Test', allowedTools: ['utils'] }); created.contextManager.addMessage('user', [{ type: 'text', text: 'Complete.' }]); h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Completed' }])); diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index a7f156b6d..e7e368b66 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -197,12 +197,12 @@ describe('batched wake routing with conversation forks', () => { it('the addressed route applies only to the fork homed there; other forks and the trunk get none', async () => { const internals = framework as unknown as { - conversationAgentHomes: Map; - pendingRequests: Array<{ agentName: string; channelId?: string; addressed?: boolean; counterparty?: string }>; + conversationAgentHomes: Map; + pendingRequests: Array<{ agentName: string; channelId?: string; channelServerId?: string; addressed?: boolean; counterparty?: string }>; eventGate: { evaluate(info: unknown): unknown }; }; - internals.conversationAgentHomes.set('fork-room', ROOM); - internals.conversationAgentHomes.set('fork-other', GENERAL); + internals.conversationAgentHomes.set('fork-room', { serverId: 'discord', channelId: ROOM }); + internals.conversationAgentHomes.set('fork-other', { serverId: 'discord', channelId: GENERAL }); internals.eventGate.evaluate({ content: '@agent hi', eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: ROOM, tags: ['chat:addressed'], metadata: { authorId: '5' }, @@ -210,6 +210,7 @@ describe('batched wake routing with conversation forks', () => { await waitFor(() => internals.pendingRequests.some((r) => r.agentName === 'fork-other'), 'gate wake broadcast'); const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); assert.equal(byAgent.get('fork-room')?.channelId, ROOM, 'the fork that owns the channel takes the route'); + assert.equal(byAgent.get('fork-room')?.channelServerId, 'discord', "with the route's server"); assert.equal(byAgent.get('fork-room')?.addressed, true); assert.equal(byAgent.get('fork-other')?.channelId, undefined, 'a fork bound elsewhere gets no locus'); assert.ok(byAgent.has('trunk'), 'premise: the wake reached the trunk too'); diff --git a/test/cancel-surfaces-as-error.test.ts b/test/cancel-surfaces-as-error.test.ts index e8f504264..0cea40f13 100644 --- a/test/cancel-surfaces-as-error.test.ts +++ b/test/cancel-surfaces-as-error.test.ts @@ -518,8 +518,8 @@ async function toolTurn(tool: 'end' | 'fetch', reports: CancelReport) { // turn's end writes its delivery receipt. Everything else the framework // asks of the registry is a no-op. const channels: Record = { - resolveLocus: () => 'chan-live', - routeSpeech: async (_agent: string, _text: string, locus?: string | null) => (locus ? { delivered: true, channelId: locus } : null), + resolveLocus: () => ({ serverId: 'stub', channelId: 'chan-live' }), + routeSpeech: async (_agent: string, _text: string, locus?: { serverId: string; channelId: string } | null) => (locus ? { delivered: true, serverId: locus.serverId, channelId: locus.channelId } : null), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, diff --git a/test/channel-agent-words.test.ts b/test/channel-agent-words.test.ts index 7f82e6619..5b6fcd419 100644 --- a/test/channel-agent-words.test.ts +++ b/test/channel-agent-words.test.ts @@ -17,7 +17,7 @@ import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { PublishRefusalReason } from '../src/index.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { McplRequestError } from '../src/mcpl/server-connection.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; @@ -94,7 +94,8 @@ test('a channel tool that can\'t find the channel says what finds it', async () assert.equal((await f.call('channel_close', { channelId: 'nope', serverId: 'a' })).error, 'No channel "nope" is on server "a", so nothing ran. channel_list shows the channels and their servers.'); assert.equal((await f.call('channel_decline', { channelId: 'both', messageId: 'm1' })).error, - 'Channel "both" is on more than one server ("b", "c"), so nothing ran. Call again with serverId set to one of them.'); + 'Channel "both" is on more than one server ("b:both", "c:both"), so nothing ran. ' + + 'Call again with channelId set to one of them, or with serverId set to "b" or "c".'); const unknown = await f.call('channel_frobnicate', {}); assert.equal(unknown.isError, true); assert.equal(unknown.error, 'There\'s no channel tool "channel_frobnicate", so nothing ran. Use one of the channel tools you were given.'); @@ -294,7 +295,8 @@ test('a channel_open or channel_close that a later decision overtook says which f.registry.enterTuneOut('a', 'y', { epochId: 'e1', cadenceSeconds: 60, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); await answer(); assert.equal((await openingY).error, 'Channel "y" was tuned out meanwhile: its messages go to the subconscious, and channel_open ' + - 'doesn\'t change that. Call tune_out with channelId "y" and mode "cancel" for it to resume normal attention.'); + 'doesn\'t change that. Call tune_out with channelId "y" and mode "cancel" for it to resume normal attention; ' + + 'cancelling delivers the diverted backlog.'); // And a close it overtook says so too. server.sendChannelsOpen = async ({ channelId }) => ({ channel: descriptor(channelId) }); @@ -386,7 +388,7 @@ test('a granted server\'s channels open, close and take speech while this host i f.connecting.add('a'); assert.equal((await f.call('channel_open', { channelId: 'x' })).success, true); assert.equal((await f.call('channel_publish', { channelId: 'x', content: 'hi' })).success, true); - assert.equal((await f.registry.routeSpeech('agent', 'hello', 'x'))?.delivered, true); + assert.equal((await f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }))?.delivered, true); assert.equal((await f.call('channel_close', { channelId: 'x' })).success, true); assert.deepEqual(f.failures, []); }); @@ -442,7 +444,7 @@ test('a send to a server that isn\'t up, or isn\'t permitted, says why in the sa assert.equal(forAgent.error, `Nothing was posted to channel "x": ${reason}.`); assert.equal(forAgent.reason, refusal, reason); f.failures.length = 0; - assert.equal(await f.registry.routeSpeech('agent', 'hello', 'x'), null); + assert.equal(await f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }), null); assert.deepEqual(f.failures.map((failure) => failure.reason), [reason], 'speech gives the same reason'); } }); @@ -465,7 +467,7 @@ test('speech that has to open its channel says why the open didn\'t happen, and f.servers.set('a', up(['channels.lifecycle'])); const failed = async () => { f.failures.length = 0; - assert.equal(await f.registry.routeSpeech('agent', 'hello', 'x'), null); + 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 ' + @@ -488,7 +490,7 @@ test('words that waited for lifecycle work the server dropped during say what he for (let i = 0; i < 50 && answers.length === 0; i++) await tick(); assert.equal(answers.length, 1, 'the first open went out'); const second = f.call('channel_open', { channelId: 'x' }); - const speech = f.registry.routeSpeech('agent', 'hello', 'x'); + const speech = f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }); await tick(); // The server drops: the held open gets no answer, and the next finds it down. f.servers.set('a', down(true)); @@ -1167,7 +1169,7 @@ test('a decline whose channel stops being registered while its close is out offe // it's left running and awaited once that close is answered. registering = f.registry.handleRegister('a', { channels: [descriptor('y')] }); for (let i = 0; i < 5; i++) await tick(); - assert.equal(f.registry.getChannelServerId('y'), 'a', 'y is registered again while the close is out'); + assert.ok(f.registry.getDescriptor({ serverId: 'a', channelId: 'y' }), 'y is registered again while the close is out'); }, 'its registration went and came back while the close was out. Other work for it that\'s still under way applies the ' + 'decline when its turn comes, so there\'s no need to decline again to close it', retry], ]; @@ -1260,7 +1262,7 @@ test('words whose open completed, then met a close decided while later work held for (let i = 0; i < 50 && answers.length < count; i++) await tick(); assert.equal(answers.length, count, 'the open went out'); }; - const speech = f.registry.routeSpeech('agent', 'hello', 'x'); + const speech = f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }); await sent(1); const backscroll = f.call('channel_open', { channelId: 'x', backscroll: 5 }); answers.shift()!(); @@ -1291,7 +1293,7 @@ test('words checked against a connection the registry replaced while they waited const opening = f.call('channel_open', { channelId: 'x' }); for (let i = 0; i < 50 && answers.length === 0; i++) await tick(); // The words wait for the open, checked against the connection then. - const speech = f.registry.routeSpeech('agent', 'hello', 'x'); + const speech = f.registry.routeSpeech('agent', 'hello', { serverId: 'a', channelId: 'x' }); await tick(); // The replacement takes the old connection's place, whose grant goes // with it, as a closed connection's does; it answers the open's retry. @@ -1330,12 +1332,12 @@ test('a channel tool on a server this host is connecting to says so, from the fr await new Promise((resolve) => setTimeout(resolve, 10)); } const registry = internals.channelRegistry!; - (registry as unknown as { channels: Map }).channels.set('slow:c1', { serverId: 'slow', descriptor: descriptor('c1'), open: false }); + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'slow', channelId: 'c1' }), { serverId: 'slow', descriptor: descriptor('c1'), open: false }); const publish = () => registry.handleChannelToolCall('channel_publish', { channelId: 'c1', content: 'hi' }); assert.equal((await publish()).error, 'Nothing was posted to channel "c1": this host is connecting to the "slow" server, so try again in a moment.'); await framework.disconnectMcplServer('slow'); await connect; - (registry as unknown as { channels: Map }).channels.set('slow:c1', { serverId: 'slow', descriptor: descriptor('c1'), open: false }); + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'slow', channelId: 'c1' }), { serverId: 'slow', descriptor: descriptor('c1'), open: false }); assert.equal((await publish()).error, 'Nothing was posted to channel "c1": the "slow" server isn\'t connected, and this host ' + 'won\'t reconnect to it on its own; if you need it, ask whoever runs this host.'); } finally { @@ -1366,7 +1368,7 @@ test('a registered server reads as connecting while the policy exchange of the t const connection = { id: 'cold', grant: CapabilityGrant.empty(), isConnected: true, transportEpoch: 2 }; internals.mcplServerRegistry!.servers.set('cold', connection); const registry = internals.channelRegistry!; - (registry as unknown as { channels: Map }).channels.set('cold:c1', { serverId: 'cold', descriptor: descriptor('c1'), open: true }); + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'cold', channelId: 'c1' }), { serverId: 'cold', descriptor: descriptor('c1'), open: true }); const publish = async () => (await registry.handleChannelToolCall('channel_publish', { channelId: 'c1', content: 'hi' })).error; const notPermitted = 'Nothing was posted to channel "c1": this host isn\'t permitted to post to the "cold" server\'s channels, ' + 'and trying again won\'t change that; if you need it, ask whoever runs this host.'; @@ -1384,7 +1386,7 @@ test('a registered server reads as connecting while the policy exchange of the t claims.ids.set('cold', { restarting: 'cold' }); assert.equal(await publish(), 'Nothing was posted to channel "c1": this host is connecting to the "cold" server, so try again in a moment.'); claims.ids.delete('cold'); - (registry as unknown as { channels: Map }).channels.set('away:c2', { serverId: 'away', descriptor: descriptor('c2'), open: true }); + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'away', channelId: 'c2' }), { serverId: 'away', descriptor: descriptor('c2'), open: true }); const publishAway = async () => (await registry.handleChannelToolCall('channel_publish', { channelId: 'c2', content: 'hi' })).error; assert.equal(await publishAway(), 'Nothing was posted to channel "c2": the "away" server isn\'t connected, and this host won\'t ' + 'reconnect to it on its own; if you need it, ask whoever runs this host.'); diff --git a/test/channel-decision-log-first.test.ts b/test/channel-decision-log-first.test.ts index 73e0ed750..067c8756a 100644 --- a/test/channel-decision-log-first.test.ts +++ b/test/channel-decision-log-first.test.ts @@ -153,7 +153,7 @@ test('a channel_close whose record fails to append answers that nothing changed assert.equal(closeCalls.length, closesBefore, 'nothing went to the server'); const retried = await registry.handleChannelToolCall('channel_close', { channelId: 'c1' }); - assert.deepEqual(retried.data, { channelId: 'c1', status: 'closed' }); + assert.deepEqual(retried.data, { channelId: 'c1', serverId: 'discord', status: 'closed' }); assert.deepEqual(decision(registry, 'c1'), { state: 'closed', source: 'agent-tool' }); const restarted = makeRegistry(store).registry; @@ -214,7 +214,7 @@ test('speech whose open fails to append its decision says nothing changed, and w // A store's words that end in a period lose it where the reason composes // them. failNext(decisionWrite, 'The disk is full.'); - const outcome = await registry.deliverSpeech('agent', 'hello', 'c1'); + const outcome = await registry.deliverSpeech('agent', 'hello', { serverId: 'discord', channelId: 'c1' }); assert.equal(outcome.status, 'failed'); assert.equal(outcome.reason, "opening the channel for these words didn't complete: this host couldn't record the decision: " + 'The disk is full, so try again; if it fails the same way, ask whoever runs this host'); diff --git a/test/channel-label-history.test.ts b/test/channel-label-history.test.ts index b5c869593..08ecf3ac2 100644 --- a/test/channel-label-history.test.ts +++ b/test/channel-label-history.test.ts @@ -163,7 +163,7 @@ test('resolveProseTargetDurable prefers the live path and only falls back to his // Live path resolves uniquely against the live channel — must win over // the (stale) history entry for the ghost channel. const live = registry.resolveProseTarget('shared-label'); - assert.deepEqual(live, { channelId: 'discord:g1:live', label: 'shared-label' }); + assert.deepEqual(live, { channelId: 'discord:g1:live', serverId: 'discord', label: 'shared-label' }); assert.deepEqual(registry.resolveProseTargetDurable('shared-label'), live); }); @@ -286,6 +286,7 @@ test('a label-less descriptor from handleRegister does not crash and does not po await registry.handleRegister('discord', { channels: [descriptor('discord:g1:c2', '#general')] }); assert.deepEqual(registry.resolveProseTargetDurable('#general'), { channelId: 'discord:g1:c2', + serverId: 'discord', label: '#general', }); assert.doesNotThrow(() => registry.resolveProseTargetDurable('#anything-else')); @@ -300,7 +301,7 @@ test('a channel renamed A -> B -> A is still resolvable by a name from BEFORE th await registry.handleChanged('discord', { updated: [descriptor('discord:g1:c1', '#alpha')] }); // Live-path check: '#alpha' is current again, so it resolves live. - assert.deepEqual(registry.resolveProseTarget('#alpha'), { channelId: 'discord:g1:c1', label: '#alpha' }); + assert.deepEqual(registry.resolveProseTarget('#alpha'), { channelId: 'discord:g1:c1', serverId: 'discord', label: '#alpha' }); // The interesting case: '#beta' is a name from BEFORE the most recent // rename (A -> B -> A), not the current OR immediately-previous label — @@ -378,6 +379,7 @@ test('a DM registered live resolves after a restart via @name, not just the lite // establishes this is real DM-shaped addressing, not a coincidence. assert.deepEqual(first.resolveProseTarget('@antra'), { channelId: 'discord:dm:antra', + serverId: 'discord', label: 'DM: antra', }); @@ -445,6 +447,7 @@ test('a DM whose live username differs from its display label resolves by USERNA // not an invented requirement. assert.deepEqual(first.resolveProseTarget('@antra_tessera'), { channelId: 'discord:dm:42', + serverId: 'discord', label: 'DM: Tess', }); @@ -481,6 +484,7 @@ test('a DM classified only via metadata.channelType (non-conventional id/label) // Live check: classified as a DM live via metadata alone. assert.deepEqual(first.resolveProseTarget('<@42>'), { channelId: 'private-room-42', + serverId: 'discord', label: 'Tess', }); diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 418a7214d..526e9f43f 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -249,7 +249,7 @@ for (const delivery of ['speech', 'reply'] as const) { // delivery joins it: the channel was already decided open, so a reply // reports it already open, and nothing sends a third RPC. const sent = delivery === 'speech' - ? f.registry.routeSpeech('resident', 'hello', 'x').then((result) => result?.delivered === true) + ? f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }).then((result) => result?.delivered === true) : f.registry.openIfClosedForSend('x', 'test').then((result) => result.status === 'already-open'); await f.drain(close, sent); assert.equal(await sent, true); @@ -266,7 +266,7 @@ test('automatic opening accepts a newer tune-out established during its RPC with const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor()] }); f.held.add('x'); - const speech = f.registry.routeSpeech('resident', 'hello', 'x'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); await tick(); f.registry.enterTuneOut('test', 'x', { epochId: 'mid-open-epoch', cadenceSeconds: 60, backlogCap: 20, maxWakes: 3, startedAtSequence: 1, @@ -290,7 +290,7 @@ test('a failed backscroll open preserves an already-live subscription', async () 'Channel "x" is open, but the history you asked for didn\'t come: fixture lifecycle failure. ' + 'Calling channel_open again with backscroll tries once more.', 'the channel is still open'); assert.equal(f.isOpen(), true, 'a failed redundant open is not a confirmed close'); - const speech = f.registry.routeSpeech('resident', 'hello', '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.calls.length, 1, 'delivery must not need another open after a failed backscroll'); @@ -303,7 +303,7 @@ test('speech does not replace a pending explicit close with a new open decision' f.held.add('x'); const close = f.tool('close'); await tick(); - const speech = f.registry.routeSpeech('resident', 'tail of the turn', 'x'); + const speech = f.registry.routeSpeech('resident', 'tail of the turn', { serverId: 'test', channelId: 'x' }); await f.drain(close, speech); assert.equal((await close).success, true); assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); @@ -369,7 +369,7 @@ for (const change of ['announced again as inbound-only', 'removed'] as const) { : { added: [{ ...descriptor('x'), direction: 'inbound' }] }); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); - assert.equal(await f.registry.routeSpeech('resident', 'hello', 'x'), null); + assert.equal(await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }), null); assert.equal(f.notices.length, 1, 'the opening was announced'); assert.deepEqual(f.publishedWhileOpen, [], 'nothing was published'); assert.match(f.routeFailures[0]?.reason ?? '', change === 'removed' @@ -392,7 +392,7 @@ for (const answer of ['opens', 'fails'] as const) { announced = f.registry.handleChanged('test', { added: [descriptor('x', false, 'replacement')] }); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); - const speech = f.registry.routeSpeech('resident', 'hello', 'x'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); for (let i = 0; i < 5; i++) await tick(); assert.equal(f.notices.length, 1, 'the opening was announced'); assert.equal(f.pending.length, 1, "the new registration's open is in flight"); @@ -425,7 +425,7 @@ test('a channel retargeted while its opening is announced takes the words only o 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', '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.isOpen('x'), false); @@ -450,7 +450,7 @@ test('a channel registered again while the words wait for a new registration tak announced = f.registry.handleChanged('test', { added: [descriptor('x', false, 'second')] }); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); - const speech = f.registry.routeSpeech('resident', 'hello', '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 second registration's open is in flight"); const again = f.registry.handleChanged('test', { added: [descriptor('x', false, 'third')] }); @@ -510,7 +510,7 @@ test('a close decided while its opening is announced refuses the words, as a clo closing = f.tool('close'); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); - const speech = f.registry.routeSpeech('resident', 'hello', 'x'); + const speech = f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); for (let i = 0; i < 5; i++) await tick(); assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); assert.deepEqual(f.publishedWhileOpen, [], 'nothing is published while the close is queued'); @@ -597,7 +597,7 @@ test('a channel registered again as inbound-only while its opening is announced announced = f.registry.handleChanged('test', { added: [{ ...descriptor('x', false, 'replacement'), direction: 'inbound' }] }); }); await f.registry.handleChanged('test', { added: [descriptor('x')] }); - const speech = f.registry.routeSpeech('resident', 'hello', '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 registration's open is in flight"); assert.match(f.routeFailures[0]?.reason ?? '', /^inbound-only: server "test" declared this channel's direction "inbound"/); @@ -627,7 +627,7 @@ for (const opener of ['tool', 'reconcile'] as const) { assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); assert.equal(f.isOpen(), false); assert.equal(f.actual.get('x'), false); - assert.equal(f.registry.inboundOnlyServer('x', 'test'), 'test', "the late answer's declaration applies"); + assert.equal(f.registry.inboundOnlyServer({ serverId: 'test', channelId: 'x' }), 'test', "the late answer's declaration applies"); const opens = f.calls.filter((c) => c.kind === 'open').length; const outcome = await f.registry.publish('resident', 'words', { serverId: 'test', channelId: 'x' }); assert.equal(outcome.status, 'failed'); @@ -647,7 +647,7 @@ test("an open answered for another channel applies nothing of its answer to the assert.equal((await f.tool('open')).success, false); assert.ok(f.traces.some((e) => e.type === 'mcpl:channel-open-substituted' && e.channelId === 'x'), 'the open reached the server and was answered for another channel'); - assert.equal(f.registry.inboundOnlyServer('x', 'test'), undefined, "the other channel's declaration isn't applied"); + assert.equal(f.registry.inboundOnlyServer({ serverId: 'test', channelId: 'x' }), undefined, "the other channel's declaration isn't applied"); }); test("an open's answer for a target the channel has since left applies nothing; the new target's answer decides", async () => { @@ -660,7 +660,7 @@ test("an open's answer for a target the channel has since left applies nothing; await f.drain(opening); assert.deepEqual(f.calls.map((c) => c.address), [{ value: 'original' }, { value: 'new-target' }]); assert.equal(f.isOpen(), true); - assert.equal(f.registry.inboundOnlyServer('x', 'test'), undefined, "the old target's declaration isn't applied"); + assert.equal(f.registry.inboundOnlyServer({ serverId: 'test', channelId: 'x' }), undefined, "the old target's declaration isn't applied"); }); test('waiting for a backscroll does not issue new opens or auto-open notices for each speech', async () => { @@ -670,8 +670,8 @@ test('waiting for a backscroll does not issue new opens or auto-open notices for f.held.add('x'); const backscroll = f.registry.handleChannelToolCall('channel_open', { serverId: 'test', channelId: 'x', backscroll: 5 }); await tick(); - const speech1 = f.registry.routeSpeech('resident', 'one', 'x'); - const speech2 = f.registry.routeSpeech('resident', 'two', 'x'); + const speech1 = f.registry.routeSpeech('resident', 'one', { serverId: 'test', channelId: 'x' }); + const speech2 = f.registry.routeSpeech('resident', 'two', { serverId: 'test', channelId: 'x' }); await f.drain(backscroll, speech1, speech2); assert.equal((await speech1)?.delivered, true); assert.equal((await speech2)?.delivered, true); @@ -685,7 +685,7 @@ for (const delivery of ['speech', 'reply'] as const) { await f.registry.handleChanged('test', { added: [descriptor()] }); f.held.add('x'); const opening = delivery === 'speech' - ? f.registry.routeSpeech('resident', 'hello', 'x') + ? f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }) : f.registry.openIfClosedForSend('x', 'test'); await tick(); const replacement = f.registry.handleChanged('test', { @@ -723,7 +723,7 @@ test('a fresh delivery into a settled closed channel remains a new engagement', await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); assert.equal((await f.tool('close')).success, true); assert.equal(f.actual.get('x'), false); - const delivered = await f.registry.routeSpeech('resident', 'a new engagement', 'x'); + const delivered = await f.registry.routeSpeech('resident', 'a new engagement', { serverId: 'test', channelId: 'x' }); assert.equal(delivered?.delivered, true); assert.equal(f.registry.getDesiredState('test', 'x'), 'open'); assert.equal(f.notices.length, 1); @@ -735,7 +735,7 @@ test('a close that completes while delivery joins a backscroll remains authorita 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', 'x'); + const speech = f.registry.routeSpeech('resident', 'tail', { serverId: 'test', channelId: 'x' }); const close = f.tool('close'); await f.drain(backscroll, speech, close); assert.equal((await close).success, true); @@ -750,8 +750,8 @@ test('concurrent delivery into a closed channel shares one opening and one notic await f.registry.handleChanged('test', { added: [descriptor()] }); f.calls.length = 0; f.held.add('x'); - const first = f.registry.routeSpeech('resident', 'one', 'x'); - const second = f.registry.routeSpeech('resident', 'two', 'x'); + const first = f.registry.routeSpeech('resident', 'one', { serverId: 'test', channelId: 'x' }); + const second = f.registry.routeSpeech('resident', 'two', { serverId: 'test', channelId: 'x' }); await f.drain(first, second); assert.equal((await first)?.delivered, true); assert.equal((await second)?.delivered, true); @@ -847,7 +847,7 @@ for (const kind of ['speech', 'reply'] as const) { await f.registry.handleChanged('test', { updated: [descriptor('x', true, 'B')] }); assert.equal(f.isOpen(), false); const delivery = kind === 'speech' - ? f.registry.routeSpeech('resident', 'waiting reply', 'x') + ? f.registry.routeSpeech('resident', 'waiting reply', { serverId: 'test', channelId: 'x' }) : f.registry.openIfClosedForSend('x', 'test'); await f.drain(closing, delivery); assert.equal(f.registry.getDesiredState('test', 'x'), 'closed'); @@ -874,7 +874,7 @@ for (const change of ['address', 'type'] as const) { serverId: 'test', channelId: 'x', backscroll: 5, }); await tick(); - const speech = f.registry.routeSpeech('resident', 'waiting reply', 'x'); + const speech = f.registry.routeSpeech('resident', 'waiting reply', { serverId: 'test', channelId: 'x' }); const updated = change === 'address' ? descriptor('x', true, 'B') : { ...descriptor('x', true, 'A'), type: 'new-type' }; @@ -900,7 +900,7 @@ test('a failed RPC from a replaced connection cannot preserve its old open confi serverId: 'test', channelId: 'x', backscroll: 5, }); await tick(); - const speech = f.registry.routeSpeech('resident', 'waiting reply', 'x'); + const speech = f.registry.routeSpeech('resident', 'waiting reply', { serverId: 'test', channelId: 'x' }); f.replaceServer(); f.pending.shift()!.fail(); await f.drain(backscroll, speech); @@ -921,7 +921,7 @@ test('descriptor churn exhausts convergence and fails the tool plus joined deliv serverId: 'test', channelId: 'x', backscroll: 5, }).then((result) => { toolSettled = true; return result; }); await tick(); - const speech = f.registry.routeSpeech('resident', 'waiting reply', 'x') + const speech = f.registry.routeSpeech('resident', 'waiting reply', { serverId: 'test', channelId: 'x' }) .then((result) => { speechSettled = true; return result; }); const reply = f.registry.openIfClosedForSend('x', 'test') .then((result) => { replySettled = true; return result; }); @@ -956,7 +956,7 @@ test('descriptor churn exhausts convergence and fails the tool plus joined deliv await f.drain(opening, speech, reply); } f.held.delete('x'); - const retry = await f.registry.routeSpeech('resident', 'a later stable retry', 'x'); + const retry = await f.registry.routeSpeech('resident', 'a later stable retry', { serverId: 'test', channelId: 'x' }); assert.equal(retry?.delivered, true); assert.equal(f.calls.length, 6, 'later delivery reconfirms the latest target before publishing'); assert.equal((await f.tool('close')).success, true, 'exhaustion releases the queue'); @@ -1072,7 +1072,7 @@ for (const delivery of ['reply', 'speech'] as const) { 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', 'x'))?.delivered, true); + 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]); } @@ -1095,7 +1095,7 @@ test('speech into a channel decided closed keeps publishing where no open can su 'need it, ask whoever runs this host.'); assert.equal(f.registry.getDesiredState('test', 'x'), 'closed', 'the refused close is still recorded as a decision'); f.calls.length = 0; - assert.equal((await f.registry.routeSpeech('resident', 'hello', 'x'))?.delivered, true); + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); assert.deepEqual(f.publishedWhileOpen, [true]); assert.deepEqual(f.calls, [], 'no open is attempted where it could not succeed'); assert.equal(f.notices.length, 0); @@ -1117,7 +1117,7 @@ test('a failed explicit open says the open stays recorded, and a later delivery assert.equal(f.registry.getDesiredState('test', 'x'), 'open', 'the decision outlives a failed round-trip'); f.held.delete('x'); await tick(); - assert.equal((await f.registry.routeSpeech('resident', 'hello', 'x'))?.delivered, true); + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); assert.equal(f.notices.length, 0, 'the resident was already told the open stays recorded'); assert.deepEqual(f.calls.map((c) => c.kind), ['open', 'open']); }); @@ -1137,7 +1137,7 @@ for (const delivery of ['speech', 'reply'] as const) { assert.equal(f.isOpen(), false, 'a failed open leaves the new target unconfirmed'); f.calls.length = 0; if (delivery === 'speech') { - assert.equal((await f.registry.routeSpeech('resident', 'hello', 'x'))?.delivered, true); + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); assert.deepEqual(f.publishedWhileOpen, [true]); } else { assert.equal((await f.registry.openIfClosedForSend('x', 'test')).status, 'already-open'); @@ -1162,7 +1162,7 @@ for (const delivery of ['speech', 'reply'] as const) { f.held.delete('x'); assert.equal(f.isOpen(), false); if (delivery === 'speech') { - assert.equal((await f.registry.routeSpeech('resident', 'hello', 'x'))?.delivered, true); + assert.equal((await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }))?.delivered, true); assert.deepEqual(f.publishedWhileOpen, [true]); } else { assert.equal((await f.registry.openIfClosedForSend('x', 'test')).status, 'already-open'); @@ -1209,7 +1209,7 @@ test('speech joined to a failed backscroll still reaches a channel that stayed o 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', 'joined', 'x'); + const speech = f.registry.routeSpeech('resident', 'joined', { serverId: 'test', channelId: 'x' }); f.pending.shift()!.fail(); assert.equal((await backscroll).success, false); assert.equal((await speech)?.delivered, true, 'a failed redundant open leaves the channel usable for a joined delivery'); @@ -1351,6 +1351,29 @@ test('a close that lands while the coalescer holds a message stands', async () = assert.equal(f.isOpen(), false, 'the close receipt stands'); }); +test("a coalesced message the coalescer admits after its server was removed sets no default, so another server's default stands", async () => { + let admit!: () => void; + const f = fixture(undefined, undefined, async (_serverId, held) => { + await new Promise((resolve) => { admit = resolve; }); + return { messageId: held.messageId, accepted: true }; + }); + await f.registry.handleChanged('test', { added: [descriptor('x', true)] }); + // Another server's message set the default before this one arrived. + const internal = f.registry as unknown as { + defaultPublishChannel: { serverId: string; channelId: string } | null; defaultPublishMessageId: string | null; defaultPublishThreadId?: string; + }; + internal.defaultPublishChannel = { serverId: 'other', channelId: 'z' }; + internal.defaultPublishMessageId = 'm-other'; + const incoming = f.registry.handleIncoming('test', { messages: [{ ...message(), coalesce: { key: 'subject' } }] }); + await tick(); + f.registry.removeServer('test'); + admit(); + await incoming; + assert.deepEqual(f.registry.getDefaultPublishChannel(), { serverId: 'other', channelId: 'z' }, "the other server's default stands"); + assert.deepEqual(f.registry.incomingFor(), { channel: { serverId: 'other', channelId: 'z' }, messageId: 'm-other', threadId: undefined }, + "no message on the removed server is given as the one replied to"); +}); + test('a retarget of a channel decided open opens the new target; one decided closed sends nothing', async () => { const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', true, 'A'), descriptor('y', false, 'A')] }); @@ -1839,7 +1862,7 @@ test("a reply whose open fails puts back the agent's close", async () => { await tick(); f.pending.shift()!.fail(); f.held.delete('x'); - assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', label: 'x', opening: true }, 'an open that would have opened the channel'); + 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"); }); @@ -1859,7 +1882,7 @@ 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', label: 'x', opening: false }, 'nothing to tell an agent that just closed it'); + 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(decision(f.registry), { state: 'closed', source: 'agent-tool' }, "the agent's close stands"); assert.equal(f.isOpen(), false); }); @@ -1868,7 +1891,7 @@ test("a reply's failed open after the agent's channel_open during its round-trip const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); const { answer, meanwhile } = await replyWhoseOpenFails(f, () => [f.tool('open')]); - assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: false }, "the agent's own open answers for itself"); + assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }, "the agent's own open answers for itself"); assert.deepEqual(f.traces.filter((t) => t.type === 'mcpl:channel-open-failed').map((t) => t.error), ['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"); @@ -1883,7 +1906,7 @@ test("a reply's failed open whose put-back can't be recorded is still an opening failAppend(f.registry, 1); // the open's own record goes through; the put-back's doesn't const closes = f.calls.filter((c) => c.kind === 'close').length; const { answer } = await replyWhoseOpenFails(f); - assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: true, selectedForOpening: 'unrecorded' }); + assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: true, selectedForOpening: 'unrecorded' }); assert.deepEqual(decision(f.registry), { state: 'open', source: 'opened-by-reply' }, 'the channel stays selected for opening'); await tick(); await tick(); assert.equal(f.calls.filter((c) => c.kind === 'close').length, closes, 'no close is sent: the decision stays open'); @@ -1952,7 +1975,7 @@ test("when an open by speech or a reply fails while the server announces the cha 'The channel is selected for opening again, because its server lists it as open by default'); } else { const { answer } = await replyWhoseOpenFails(f, reannounce); - assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: true, selectedForOpening: 'server-bootstrap' }); + assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: true, selectedForOpening: 'server-bootstrap' }); } await tick(); await tick(); assert.deepEqual(decision(f.registry), { state: 'open', source: 'server-bootstrap' }, @@ -1968,7 +1991,7 @@ test("when a reply's open fails after the subscription policy came to admit the const f = fixture(); await f.registry.handleChanged('test', { added: [descriptor('x', false)] }); const { answer } = await replyWhoseOpenFails(f, () => { f.registry.setSubscriptionPolicy('test', 'auto'); return []; }); - assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', label: 'x', opening: true, selectedForOpening: 'subscription-policy' }); + assert.deepEqual(answer, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: true, selectedForOpening: 'subscription-policy' }); assert.deepEqual(f.traces.filter((t) => t.type === 'mcpl:channel-open-failed').map((t) => t.error), ['fixture lifecycle failure; the channel is selected for opening again, because the subscription policy admits it']); await tick(); await tick(); @@ -2298,9 +2321,11 @@ async function replyOpenFailedNotices(opts: { framework.mcplServerRegistry = { getAllServers: () => [server], getServer: (id: string) => (id === 'chat-server' ? server : null) }; framework.checkpointManager = null; framework.turnRouting = new Map(); + framework.openLogical = new Map(); framework.agents = new Map((opts.live ?? [agentName]).map((name) => [name, { canUseTool: (tool: string) => !(opts.denied ?? []).includes(tool) }])); framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map(opts.homes ?? []); + // A fork's home is a channel on the send's server. + framework.conversationAgentHomes = new Map((opts.homes ?? []).map(([name, id]) => [name, { serverId: 'chat-server', channelId: id }])); framework.primaryAgentName = opts.primary; framework.emitTrace = () => {}; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { @@ -2313,11 +2338,13 @@ async function replyOpenFailedNotices(opts: { framework.channelRegistry = { openIfClosedForSend: async () => { opened = true; - if (opts.opened) return { status: 'opened', channelId: 'x', label: '#x' }; - return { status: 'open-failed', channelId: 'x', label: '#x', opening: opts.opening, + if (opts.opened) return { status: 'opened', channelId: 'x', serverId: 'chat-server', label: '#x' }; + return { status: 'open-failed', channelId: 'x', serverId: 'chat-server', label: '#x', opening: opts.opening, ...(opts.selectedForOpening ? { selectedForOpening: opts.selectedForOpening } : {}) }; }, directsLifecycle: () => opts.directs && !(opts.dropsDuring && opened), + // The notice names the channel as the agent is shown it: here its id. + nameShown: (channel: { channelId: string }) => channel.channelId, }; const handled = (l: string) => l.includes('open-on-send failed') || l.includes('opened (send implies engagement)'); const error = console.error; @@ -2532,7 +2559,7 @@ test("where a reply's reader can't close a channel selected for opening, the pri framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map(c.homes ?? []); + framework.conversationAgentHomes = new Map((c.homes ?? []).map(([name, id]) => [name, { serverId: 'srv', channelId: id }])); // Forks are made where conversations are routed, here from the primary. framework.conversationRouter = c.homes ? { templateAgent: 'primary' } : undefined; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { @@ -2657,12 +2684,12 @@ test('the opened notice names channel_close only where the agent it\'s addressed framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map(c.homes ?? []); + framework.conversationAgentHomes = new Map((c.homes ?? []).map(([name, id]) => [name, { serverId: 'srv', channelId: id }])); // Each fork is bound to its home, unless the sweep has ended its binding; // of two forks with one home, the router binds the later. const bound = new Map((c.homes ?? []).filter(([fork]) => !(c.expired ?? []).includes(fork)).map(([fork, home]) => [home, fork])); framework.conversationRouter = c.router - ? { templateAgent: c.template ?? 'primary', getBinding: (id: string) => (bound.has(id) ? { channelId: id, agentName: bound.get(id) } : undefined) } + ? { templateAgent: c.template ?? 'primary', getBinding: (ref: { channelId: string }) => (bound.has(ref.channelId) ? { channelId: ref.channelId, agentName: bound.get(ref.channelId) } : undefined) } : undefined; framework.subconsciousAgentName = c.subconscious; // Residents, and the subconscious through its merged view, read one shared @@ -2670,7 +2697,10 @@ test('the opened notice names channel_close only where the agent it\'s addressed framework.sharedSlotAgents = new Set([...framework.agents.values()] .filter((a: { name: string }) => !framework.conversationAgentHomes.has(a.name) && !(c.isolated ?? []).includes(a.name))); const labels = new Map(c.labels ?? []); - framework.channelRegistry = { getDescriptor: (id: string) => (labels.has(id) ? { id, label: labels.get(id) } : undefined) }; + framework.channelRegistry = { + getDescriptor: (ref: { channelId: string }) => (labels.has(ref.channelId) ? { id: ref.channelId, label: labels.get(ref.channelId) } : undefined), + nameShown: (ref: { channelId: string }) => ref.channelId, + }; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { told.push({ text: content[0]!.text, metadata, ...(routed ? { forAgent: routed.forAgent } : {}) }); return 'm'; @@ -2701,7 +2731,7 @@ test("each channel notice is written on its own, and the reader's says the prima framework.agents = new Map([['fork', { name: 'fork', canUseTool: () => true }], ['primary', { name: 'primary', canUseTool: () => true }]]); framework.primaryAgentName = 'primary'; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map([['fork', 'y']]); + framework.conversationAgentHomes = new Map([['fork', { serverId: 'srv', channelId: 'y' }]]); framework.conversationRouter = { templateAgent: 'primary' }; framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { // The reader's notice is addressed to the fork; the primary's to no one. @@ -2746,7 +2776,7 @@ test("a channel notice's inputs give the primary's by-name notice once its reade framework.usedEphemeralAgentNames = new Set(); framework.conversationAgentHomes = new Map(); // Every channel still as the notice says: open, or still waiting to open. - framework.channelRegistry = { getDesiredState: () => 'open', isChannelOpen: () => false }; + framework.channelRegistry = { getDesiredState: () => 'open', isChannelOpen: () => false, nameShown: (ref: { channelId: string }) => ref.channelId }; const error = console.error; console.error = () => {}; try { @@ -2807,7 +2837,7 @@ test("a reply's open that only reconfirms a tuned-out channel's transport, and f await tick(); f.pending.shift()!.fail(); f.held.delete('x'); - assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', label: 'x', opening: false }); + assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }); assert.equal(f.registry.getDesiredState('test', 'x'), 'tuned-out'); }); @@ -2825,7 +2855,7 @@ test("a reply's open that only reconfirms a channel selected open, and fails, is await tick(); f.pending.shift()!.fail(); f.held.delete('x'); - assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', label: 'x', opening: false }); + assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }); assert.equal(f.registry.getDesiredState('test', 'x'), 'open', 'the decision stands'); }); @@ -2842,7 +2872,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', label: 'x', opening: false }); + assert.deepEqual(await reply, { status: 'open-failed', channelId: 'x', serverId: 'test', label: 'x', opening: false }); 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' }); }); diff --git a/test/channel-lifecycle-provenance.test.ts b/test/channel-lifecycle-provenance.test.ts index ecb46dd52..cadb086f5 100644 --- a/test/channel-lifecycle-provenance.test.ts +++ b/test/channel-lifecycle-provenance.test.ts @@ -67,16 +67,6 @@ test('initiallyOpen at first sight registers open with server-bootstrap provenan assert.equal(r.getDesiredState('eidoverse', 'world:commons'), 'open'); }); -test("isChannelOpen, given a server, reads only that server's channel, though the registry's key joins the two ids with a colon", () => { - const { registry } = makeRegistry(); - // Server "a" registers channel "b:c": its key is "a:b:c", which is also - // what server "a:b" and channel "c" would make. - (registry as unknown as { channels: Map }).channels - .set('a:b:c', { serverId: 'a', descriptor: { id: 'b:c', type: 'a', label: '#c' }, open: true }); - assert.equal(registry.isChannelOpen('b:c', 'a'), true, "a's own channel"); - assert.equal(registry.isChannelOpen('c', 'a:b'), false, "another server's entry under the same key"); -}); - test("desiredStateInLog reads a server's channel's last decision from a store's lifecycle log, by the rule a registry replayed from it reads", () => { const dir = mkdtempSync(join(tmpdir(), 'desired-state-in-log-')); const store = JsStore.openOrCreate({ path: join(dir, 'store') }); diff --git a/test/channel-open-substitution.test.ts b/test/channel-open-substitution.test.ts index 6878e4dc7..f12d2db4b 100644 --- a/test/channel-open-substitution.test.ts +++ b/test/channel-open-substitution.test.ts @@ -146,7 +146,7 @@ test('a channel admitted open stays closed on a substituted answer, reports it o assert.equal(f.traces.filter((t) => t.type === 'mcpl:channel-reconcile-failed').length, 0, 'the substitution is its own diagnostic, not also a generic reconcile failure'); - const { value: delivery } = await capturingErrors(() => f.registry.routeSpeech('resident', 'hello', 'x')); + const { value: delivery } = await capturingErrors(() => f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' })); assert.equal(delivery, null); assert.deepEqual(f.published, [], 'nothing is sent into a channel the server would not confirm'); assert.equal(f.routeFailures.length, 1); @@ -168,7 +168,7 @@ test('a known-open channel loses its confirmation on a substituted answer, and r answerFor = undefined; await tick(); f.calls.length = 0; - const delivery = await f.registry.routeSpeech('resident', 'hello', 'x'); + const delivery = await f.registry.routeSpeech('resident', 'hello', { serverId: 'test', channelId: 'x' }); assert.equal(delivery?.delivered, true); assert.deepEqual(f.calls, [{ kind: 'open', id: 'x' }], 'delivery reopens the channel before sending'); assert.deepEqual(f.published, ['x']); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80e6f8ef2..eaa4b4a41 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -1,12 +1,16 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey, type ChannelRef } from '../src/mcpl/channel-registry.js'; import { McplRequestError, McplServerConnection } from '../src/mcpl/server-connection.js'; import { parseProsePrefix } from '../src/mcpl/prose-grammar.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; +/** A channel on the fixture's chat server, and one on its body server. */ +const discord = (channelId: string): ChannelRef => ({ serverId: 'discord', channelId }); +const body = (channelId: string): ChannelRef => ({ serverId: 'body', channelId }); + type RouteFailure = { conversationId: string; channelId: string | null; reason: string; textLen: number; outcome?: 'failed' | 'unknown'; detail?: string }; /** @@ -15,8 +19,8 @@ type RouteFailure = { conversationId: string; channelId: string | null; reason: */ function makeRegistry( publishResult: { delivered?: boolean } | undefined, - homeChannelResolver?: (agentName: string) => string | undefined, - activeChannelResolver?: (agentName: string) => string | undefined, + homeChannelResolver?: (agentName: string) => ChannelRef | undefined, + activeChannelResolver?: (agentName: string) => ChannelRef | undefined, onChannelAutoOpened?: (info: { conversationId?: string; serverId: string; @@ -85,12 +89,13 @@ function makeRegistry( }, ); - // findChannelEntry is private; reach it the same way the typing test reaches - // the channels map — a test-only cast, not part of the public surface. + // A channel's entry, found by its id among the registered ones (each test's + // ids are unique to one server): a test-only cast into the private map, as + // the typing test reaches it, not part of the public surface. const lookup = (channelId: string) => - (registry as unknown as { - findChannelEntry(id: string): { serverId: string; open: boolean; descriptor: { id: string; label: string; metadata?: Record } } | undefined; - }).findChannelEntry(channelId); + [...(registry as unknown as { + channels: Map } }>; + }).channels.values()].find((e) => e.descriptor.id === channelId); return { registry, failures, traces, publishCalls, openCalls, closeCalls, streamCalls, lookup, @@ -131,8 +136,9 @@ function seedRegistered(registry: ChannelRegistry, serverId: string, ...ids: str channels: Map; }).channels; for (const id of ids) { - if (!map.has(`${serverId}:${id}`)) { - map.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: serverId, label: id }, open: false }); + const key = destinationKey({ serverId, channelId: id }); + if (!map.has(key)) { + map.set(key, { serverId, descriptor: { id, type: serverId, label: id }, open: false }); } } } @@ -206,7 +212,7 @@ test('routeSpeech routes a conversation fork to its HOME channel, not the global // Two channels are live. chanA registered first; then a message arrives on // chanB, flipping the process-global defaultPublishChannel to chanB. A fork // bound to chanA must still publish to chanA. - const homes: Record = { 'conversation-chanA-g1': 'chanA' }; + const homes: Record = { 'conversation-chanA-g1': discord('chanA') }; const { registry, publishCalls } = makeRegistry( { delivered: true }, (agentName) => homes[agentName], @@ -217,7 +223,7 @@ test('routeSpeech routes a conversation fork to its HOME channel, not the global seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); // Global locus is now chanB. - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); + assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); const res = await registry.routeSpeech('conversation-chanA-g1', 'reply for A', registry.resolveLocus('conversation-chanA-g1')); assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanA' }, @@ -244,7 +250,7 @@ test('routeSpeech falls back to the global locus for the trunk agent (no home)', }); test('buildChannelContext advertises the fork home as defaultOutgoing (item 3)', () => { - const homes: Record = { 'conversation-chanA-g1': 'chanA' }; + const homes: Record = { 'conversation-chanA-g1': discord('chanA') }; const { registry } = makeRegistry( { delivered: true }, (agentName) => homes[agentName], @@ -256,11 +262,11 @@ test('buildChannelContext advertises the fork home as defaultOutgoing (item 3)', registry.handleIncoming('discord', incoming('chanB', 'hi from B')); // The fork is told chanA (where its speech actually lands)... - const forkCtx = registry.buildChannelContext('conversation-chanA-g1'); + const forkCtx = registry.buildChannelContext('conversation-chanA-g1', 'discord'); assert.equal(forkCtx?.defaultOutgoing?.channelId, 'chanA'); // ...while the trunk agent (no home) is told the global default. - const trunkCtx = registry.buildChannelContext('trunk'); + const trunkCtx = registry.buildChannelContext('trunk', 'discord'); assert.equal(trunkCtx?.defaultOutgoing?.channelId, 'chanB'); }); @@ -296,7 +302,7 @@ test('routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the // defaultPublishChannel to B. The reply must still land in A. Forks (home) // don't exist here — connectome-host never spawns them — so the ONLY thing // keeping A's answer in A is the active-triggering-channel resolver. - const active: Record = { scout: 'chanA' }; + const active: Record = { scout: discord('chanA') }; const { registry, publishCalls } = makeRegistry( { delivered: true }, () => undefined, // no fork homes (trunk-only, connectome-host mode) @@ -307,7 +313,7 @@ test('routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the registry.handleIncoming('discord', incoming('chanA', 'A: sleep && date')); seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'B: unrelated')); // flips global to chanB - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); + assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); const res = await registry.routeSpeech('scout', 'the date is ...', registry.resolveLocus('scout')); assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanA' }, @@ -320,8 +326,8 @@ test('routeSpeech precedence: fork HOME wins over the active triggering channel' // triggered from chanB — home is the strongest signal and must not regress. const { registry, publishCalls } = makeRegistry( { delivered: true }, - (n) => (n === 'conversation-chanA-g1' ? 'chanA' : undefined), - () => 'chanB', + (n) => (n === 'conversation-chanA-g1' ? discord('chanA') : undefined), + () => discord('chanB'), ); seedRegistered(registry, 'discord', 'chanA'); registry.handleIncoming('discord', incoming('chanA', 'hi')); @@ -334,7 +340,7 @@ test('routeSpeech precedence: fork HOME wins over the active triggering channel' }); test('buildChannelContext advertises the active triggering channel as defaultOutgoing (item-3 redux)', () => { - const active: Record = { scout: 'chanA' }; + const active: Record = { scout: discord('chanA') }; const { registry } = makeRegistry( { delivered: true }, () => undefined, @@ -347,7 +353,7 @@ test('buildChannelContext advertises the active triggering channel as defaultOut // The trunk is TOLD chanA (where its speech will actually land), matching // what routeSpeech resolves — not the global chanB. - const ctx = registry.buildChannelContext('scout'); + const ctx = registry.buildChannelContext('scout', 'discord'); assert.equal(ctx?.defaultOutgoing?.channelId, 'chanA'); }); @@ -357,7 +363,7 @@ test('ensureChannelRegistered keeps a DM closed while making its one-shot reply // the reply. Registering it on inbound makes it a publishable locus; the // active resolver (the woken turn's DM) then targets it. const dm = 'discord:dm:42'; - const active: Record = { scout: dm }; + const active: Record = { scout: discord(dm) }; const { registry, publishCalls, lookup, traces } = makeRegistry( { delivered: true }, () => undefined, @@ -408,7 +414,7 @@ test('routeSpeech into a closed locus opens the channel first, then delivers', a registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); lookup('dm-alice')!.open = false; - const res = await registry.routeSpeech('sol', 'a reply meant for the DM', 'dm-alice'); + const res = await registry.routeSpeech('sol', 'a reply meant for the DM', discord('dm-alice')); assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'dm-alice' }); assert.equal(openCalls.length, 1, 'delivery into a closed channel must open it'); @@ -428,7 +434,7 @@ test('routeSpeech does NOT deliver when the open-on-delivery fails', async () => const publishesBefore = publishCalls.length; setFailOpens(true); - const res = await registry.routeSpeech('sol', 'must not go out', 'dm-alice'); + const res = await registry.routeSpeech('sol', 'must not go out', discord('dm-alice')); assert.equal(res, null, 'no delivery into a channel that could not be opened'); assert.equal(publishCalls.length, publishesBefore, 'publish must not be attempted'); @@ -555,7 +561,7 @@ test('opened-by-delivery is announced to the delivering agent', async () => { registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); lookup('dm-alice')!.open = false; - await registry.routeSpeech('sol', 'reply', 'dm-alice'); + await registry.routeSpeech('sol', 'reply', discord('dm-alice')); assert.equal(autoOpened.length, 1); assert.equal(autoOpened[0]!.source, 'opened-by-delivery'); @@ -632,9 +638,9 @@ test('non-conversational markers do not retarget the default publish locus (#157 for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { lookup('chanB')!.open = false; registry.handleIncoming('discord', incoming('chanB', `[${tag}] marker`, undefined, [tag])); - assert.equal(registry.getDefaultPublishChannel(), 'chanA', + assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanA'), `a ${tag} marker on chanB must not steal the default publish locus`); - const anchor = registry.buildChannelContext()?.incoming; + const anchor = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(anchor?.messageId, 'm1', `a ${tag} marker leaves the reply anchor's message`); assert.equal(anchor?.threadId, 'thread-A', `a ${tag} marker leaves the reply anchor's thread`); // The liveness repair still runs for a marker, as for any message: a @@ -643,7 +649,7 @@ test('non-conversational markers do not retarget the default publish locus (#157 assert.equal(lookup('chanB')?.open, true, `a ${tag} marker still repairs chanB's open state`); // On the anchor's own channel, a marker leaves the anchor too. registry.handleIncoming('discord', incoming('chanA', `[${tag}] marker on A`, undefined, [tag])); - const sameChannel = registry.buildChannelContext()?.incoming; + const sameChannel = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(sameChannel?.messageId, 'm1', `a ${tag} marker on the anchor's channel leaves its message`); assert.equal(sameChannel?.threadId, 'thread-A', `a ${tag} marker on the anchor's channel leaves its thread`); } @@ -652,15 +658,15 @@ test('non-conversational markers do not retarget the default publish locus (#157 const real = incoming('chanB', 'real message on B', undefined, undefined, 'thread-B'); real.messages[0]!.messageId = 'm2'; registry.handleIncoming('discord', real); - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); - const after = registry.buildChannelContext()?.incoming; + assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); + const after = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(after?.messageId, 'm2', 'a real message becomes the reply anchor'); assert.equal(after?.threadId, 'thread-B', 'with its own thread'); // One with no thread leaves none behind. const unthreaded = incoming('chanA', 'real message on A, no thread'); unthreaded.messages[0]!.messageId = 'm3'; registry.handleIncoming('discord', unthreaded); - const last = registry.buildChannelContext()?.incoming; + const last = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(last?.messageId, 'm3', 'a real message with no thread becomes the reply anchor'); assert.equal(last?.threadId, undefined, 'and takes no thread from the one before it'); }); @@ -671,7 +677,7 @@ test('a marker on a NEVER-conversed channel leaves the locus null, not marker-an registry.handleIncoming('discord', incoming('chanB', '[edited] marker', undefined, ['chat:edited'])); assert.equal(registry.getDefaultPublishChannel(), null, 'an edit marker alone must not establish a publish locus'); - assert.equal(registry.buildChannelContext()?.incoming, undefined, + assert.equal(registry.buildChannelContext(undefined, 'discord')?.incoming, undefined, 'nor a reply anchor'); }); @@ -700,42 +706,57 @@ test('proseTargetFor: one whitespace-free token per channel that resolves back t 'discord:g2:200': 'discord:g2:200', }; for (const [id, want] of Object.entries(expected)) { - const t = registry.proseTargetFor(id); + const t = registry.proseTargetFor(discord(id)); assert.equal(t, want, `target for ${id}`); // The property that matters: the grammar reads it whole, and it routes back here. assert.equal(parseProsePrefix(`>>${t} hello`).target, t, `${t} parses as one target`); const r = registry.resolveProseTarget(t!); assert.ok('channelId' in r && r.channelId === id, `${t} resolves to ${id}`); } - assert.equal(registry.proseTargetFor('discord:nowhere'), undefined, 'unregistered → undefined'); + assert.equal(registry.proseTargetFor(discord('discord:nowhere')), undefined, 'unregistered → undefined'); }); -test('proseTargetFor: no token when none is safe (shared id across servers, whitespace-only options)', async () => { +test('proseTargetFor: a channel whose id another server shares gets a token that resolves back on its own server', async () => { const { registry } = makeRegistry({ delivered: true }); - // The same channel id registered by two connections: a resolved target names a - // channel by id alone, so either server's reply could leave through the other. + // The same channel id registered by two connections: a target naming the + // channel by id alone means both, so each one's token is a label of its own + // or its server-qualified id. await registry.handleChanged('discord-a', { added: [{ id: 'chan:7', type: 'discord', label: '#shared-a', direction: 'bidirectional' }], } as never); await registry.handleChanged('discord-b', { added: [{ id: 'chan:7', type: 'discord', label: '#shared-b', direction: 'bidirectional' }], } as never); - assert.equal(registry.proseTargetFor('chan:7', 'discord-a'), undefined); - assert.equal(registry.proseTargetFor('chan:7', 'discord-b'), undefined); - assert.equal(registry.proseTargetFor('chan:7'), undefined); + assert.equal(registry.proseTargetFor({ serverId: 'discord-a', channelId: 'chan:7' }), '#shared-a'); + assert.equal(registry.proseTargetFor({ serverId: 'discord-b', channelId: 'chan:7' }), '#shared-b'); + + // With one label between them as well, only the server-qualified id names either. + await registry.handleChanged('discord-a', { + added: [{ id: 'chan:9', type: 'discord', label: '#same', direction: 'bidirectional' }], + } as never); + await registry.handleChanged('discord-b', { + added: [{ id: 'chan:9', type: 'discord', label: '#same', direction: 'bidirectional' }], + } as never); + for (const serverId of ['discord-a', 'discord-b']) { + const t = registry.proseTargetFor({ serverId, channelId: 'chan:9' }); + assert.equal(t, `${serverId}:chan:9`); + assert.equal(parseProsePrefix(`>>${t} hello`).target, t, `${t} parses as one target`); + const r = registry.resolveProseTarget(t!); + assert.ok('channelId' in r && r.channelId === 'chan:9' && r.serverId === serverId, `${t} resolves back on ${serverId}`); + } // An id with whitespace and a label with whitespace: nothing parses as one target. await registry.handleChanged('discord-a', { added: [{ id: 'thread 42', type: 'discord', label: 'thread 42', direction: 'bidirectional' }], } as never); - assert.equal(registry.proseTargetFor('thread 42', 'discord-a'), undefined); + assert.equal(registry.proseTargetFor({ serverId: 'discord-a', channelId: 'thread 42' }), undefined); - // serverId is honoured: a channel registered only on discord-a isn't named for discord-b. + // The server is honoured: a channel registered only on discord-a isn't named for discord-b. await registry.handleChanged('discord-a', { added: [{ id: 'chan:8', type: 'discord', label: '#only-a', direction: 'bidirectional' }], } as never); - assert.equal(registry.proseTargetFor('chan:8', 'discord-a'), '#only-a'); - assert.equal(registry.proseTargetFor('chan:8', 'discord-b'), undefined); + assert.equal(registry.proseTargetFor({ serverId: 'discord-a', channelId: 'chan:8' }), '#only-a'); + assert.equal(registry.proseTargetFor({ serverId: 'discord-b', channelId: 'chan:8' }), undefined); }); // --------------------------------------------------------------------------- @@ -756,7 +777,7 @@ test('plain speech into a channel its server declared inbound-only is refused be await registerInboundOnly(registry); assert.equal(lookup('body:ears')?.open, false, 'precondition: registered closed'); - const result = await registry.routeSpeech('linn', 'Hello, room.', 'body:ears'); + const result = await registry.routeSpeech('linn', 'Hello, room.', body('body:ears')); assert.equal(result, null, 'nothing was delivered'); assert.deepEqual(publishCalls, [], 'nothing was published'); @@ -799,14 +820,12 @@ test('channel_publish into an inbound-only channel is refused, naming the declar assert.deepEqual(publishCalls, [], 'nothing was published'); }); -test('no outgoing stream starts in an inbound-only channel, by its destination or by its bare id', async () => { +test('no outgoing stream starts in an inbound-only channel', async () => { const { registry, streamCalls } = makeRegistry({ delivered: true }); await registerInboundOnly(registry); registry.sendOutgoingChunk({ serverId: 'body', channelId: 'body:ears' }, 'linn', 'inf_1', 0, 'Hello'); registry.sendOutgoingComplete({ serverId: 'body', channelId: 'body:ears' }, 'linn', 'inf_1', 'Hello'); - registry.sendOutgoingChunk('body:ears', 'linn', 'inf_2', 0, 'Hello'); - registry.sendOutgoingComplete('body:ears', 'linn', 'inf_2', 'Hello, room.'); assert.deepEqual(streamCalls, []); }); @@ -1024,39 +1043,16 @@ test('a stream keeps no record in the registry once each destination it started assert.equal(streamsKept(), 0); }); -test('a bare channel id is resolved as before, at each call: to the one server that registers it, whose grant takes the stream, and nowhere once it is inbound-only', async () => { - const { registry, sent, connect, register, revokeStreaming } = makeStreamingRegistry(); - connect('body'); - connect('a'); - connect('b'); - connect('mute'); - await register('body', 'body:room'); - await register('a', 'shared'); - await register('b', 'shared'); - await register('mute', 'mute:room'); - revokeStreaming('mute'); - +/** Never called: tsc checks it. A stream goes to the destination its publish + * went to, so a bare channel id, which would be resolved again at each call, + * is no stream target. */ +function streamTakesOnlyADestination(registry: ChannelRegistry): void { + // @ts-expect-error a bare channel id isn't a destination registry.sendOutgoingChunk('body:room', 'linn', 'inf_1', 0, 'Hello'); + // @ts-expect-error a bare channel id isn't a destination registry.sendOutgoingComplete('body:room', 'linn', 'inf_1', 'Hello'); - // Two servers register the id, so it names no one channel. - registry.sendOutgoingChunk('shared', 'linn', 'inf_2', 0, 'to whom?'); - registry.sendOutgoingComplete('shared', 'linn', 'inf_2', 'to whom?'); - // Its server's grant doesn't take the stream. - registry.sendOutgoingChunk('mute:room', 'linn', 'inf_3', 0, 'unheard'); - registry.sendOutgoingComplete('mute:room', 'linn', 'inf_3', 'unheard'); - registry.sendOutgoingChunk('body:room', 'linn', 'inf_4', 0, 'Hi'); - await registry.handleChanged('body', { - updated: [{ id: 'body:room', type: 'chat', label: 'body:room', direction: 'inbound' }], - } as never); - registry.sendOutgoingChunk('body:room', 'linn', 'inf_4', 1, '\n\nand more'); - registry.sendOutgoingComplete('body:room', 'linn', 'inf_4', 'Hi\n\nand more'); - - assert.deepEqual(sent.map((s) => [s.to, s.kind, s.inferenceId, s.text]), [ - ['body', 'chunk', 'inf_1', 'Hello'], - ['body', 'complete', 'inf_1', 'Hello'], - ['body', 'chunk', 'inf_4', 'Hi'], - ]); -}); +} +void streamTakesOnlyADestination; test("two destinations whose ids join to the same string, registered one after the other, are still two destinations to the stream", async () => { // Server a's channel `b:room` and server a:b's channel `room` would both be @@ -1092,10 +1088,10 @@ test('a channel declared outbound or bidirectional, or with no direction, still } as never); for (const id of ['body:out', 'body:both', 'body:unstated']) { - assert.ok(await registry.routeSpeech('linn', `speech to ${id}`, id), `speech delivered to ${id}`); + assert.ok(await registry.routeSpeech('linn', `speech to ${id}`, body(id)), `speech delivered to ${id}`); const published = await registry.handleChannelToolCall('channel_publish', { channelId: id, content: `publish to ${id}` }); assert.equal(published.success, true, `channel_publish delivered to ${id}`); - registry.sendOutgoingChunk(id, 'linn', 'inf_1', 0, 'x'); + registry.sendOutgoingChunk({ serverId: 'body', channelId: id }, 'linn', 'inf_1', 0, 'x'); } assert.deepEqual(failures, []); @@ -1117,9 +1113,9 @@ test('inboundOnlyServer names the declaring server for an inbound-only channel, ], } as never); - assert.equal(registry.inboundOnlyServer('body:ears'), 'body'); + assert.equal(registry.inboundOnlyServer(body('body:ears')), 'body'); for (const id of ['body:out', 'body:both', 'body:unstated', 'body:unregistered']) { - assert.equal(registry.inboundOnlyServer(id), undefined, id); + assert.equal(registry.inboundOnlyServer(body(id)), undefined, id); } }); @@ -1130,21 +1126,21 @@ test('the refusal follows the descriptor its server declared last: channels/regi await registry.handleRegister('body', { channels: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], } as never); - assert.equal(await registry.routeSpeech('linn', 'one', 'body:ears'), null); - assert.equal(registry.inboundOnlyServer('body:ears'), 'body'); + assert.equal(await registry.routeSpeech('linn', 'one', body('body:ears')), null); + assert.equal(registry.inboundOnlyServer(body('body:ears')), 'body'); // An update declares it bidirectional: speech goes out. await registry.handleChanged('body', { updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); - assert.ok(await registry.routeSpeech('linn', 'two', 'body:ears')); - assert.equal(registry.inboundOnlyServer('body:ears'), undefined); + assert.ok(await registry.routeSpeech('linn', 'two', body('body:ears'))); + assert.equal(registry.inboundOnlyServer(body('body:ears')), undefined); // Another declares it inbound-only again: refused again. await registry.handleChanged('body', { updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], } as never); - assert.equal(await registry.routeSpeech('linn', 'three', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'three', body('body:ears')), null); assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:ears'], 'only the bidirectional turn published'); assert.equal(failures.length, 2); @@ -1244,15 +1240,15 @@ test("publishForAgent traces what a rejected publish may have done: unknown afte }); test('buildChannelContext names no defaultOutgoing for a locus its server declared inbound-only', async () => { - const active: Record = { linn: 'body:ears', nell: 'body:home' }; + const active: Record = { linn: body('body:ears'), nell: body('body:home') }; const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => active[n]); await registerInboundOnly(registry); await registry.handleChanged('body', { added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], } as never); - assert.equal(registry.buildChannelContext('linn')?.defaultOutgoing, undefined, 'speech goes nowhere, so no channel is named'); - assert.equal(registry.buildChannelContext('nell')?.defaultOutgoing?.channelId, 'body:home'); + assert.equal(registry.buildChannelContext('linn', 'body')?.defaultOutgoing, undefined, 'speech goes nowhere, so no channel is named'); + assert.equal(registry.buildChannelContext('nell', 'body')?.defaultOutgoing?.channelId, 'body:home'); }); test('an inbound-only channel resolves as a speech target to an error naming the declaration, and still resolves for reading', async () => { @@ -1273,13 +1269,14 @@ test('an inbound-only channel resolves as a speech target to an error naming the const nowhere = registry.resolveProseTarget('#nowhere', { forSpeech: true }); assert.ok('error' in nowhere && !('refusedChannelId' in nowhere), 'an unknown name is no refusal'); // Reading its history still finds it. - assert.deepEqual(registry.resolveProseTarget('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); - assert.deepEqual(registry.resolveProseTargetDurable('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); + assert.deepEqual(registry.resolveProseTarget('body:ears'), { channelId: 'body:ears', serverId: 'body', label: 'Body · ears' }); + assert.deepEqual(registry.resolveProseTargetDurable('body:ears'), { channelId: 'body:ears', serverId: 'body', label: 'Body · ears' }, + 'a live channel resolves on the live path, with its server'); // No reply token is offered for it; one is for the bidirectional channel. - assert.equal(registry.proseTargetFor('body:ears', 'body'), undefined); - assert.ok(registry.proseTargetFor('body:home', 'body')); + assert.equal(registry.proseTargetFor(body('body:ears')), undefined); + assert.ok(registry.proseTargetFor(body('body:home'))); // A bidirectional channel resolves either way. - assert.deepEqual(registry.resolveProseTarget('body:home', { forSpeech: true }), { channelId: 'body:home', label: 'Body · home' }); + assert.deepEqual(registry.resolveProseTarget('body:home', { forSpeech: true }), { channelId: 'body:home', serverId: 'body', label: 'Body · home' }); }); test('a channel declared inbound-only while the host opens it for speech is refused before the publish, and stays open with the open\'s notice', async () => { @@ -1298,7 +1295,7 @@ test('a channel declared inbound-only while the host opens it for speech is refu } as never); }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:ears')), null); assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'the closed channel was opened'); assert.equal(publishCalls.length, 0, 'nothing was published after the declaration'); assert.equal(failures.length, 1); @@ -1335,7 +1332,7 @@ test("a server's error response to the publish goes through fail() as unconfirme throw new McplRequestError('The "body" server answered with an error: Channel not permitted', 'error-response'); }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:home')), null); assert.equal(publishCalls.length, 1, 'the publish was sent, and the server answered with an error'); assert.deepEqual(failures.map((f) => f.reason), ['The "body" server answered with an error: Channel not permitted (delivery uncertain)'], "the marker gives the server's own words, and says delivery is uncertain"); @@ -1355,7 +1352,7 @@ test("a lazily registered channel whose open answers inbound-only is refused bef registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); setOpenResult({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' } }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:ears')), null); assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'speech opened the channel, as for any closed locus'); assert.equal(publishCalls.length, 0, 'nothing was published after the server declared it'); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); @@ -1363,10 +1360,10 @@ test("a lazily registered channel whose open answers inbound-only is refused bef // how to close it, while the marker reports the refused speech. assert.deepEqual(announced.map((a) => [a.source, a.channels.map((c) => c.channelId)]), [['opened-by-delivery', ['body:ears']]]); assert.equal(traces.filter((t) => t.type === 'mcpl:channel-opened-by-send').length, 1); - assert.equal(registry.isChannelOpen('body:ears'), true); + assert.equal(registry.isChannelOpen(body('body:ears')), true); // The next speech is refused before anything goes to the server. - assert.equal(await registry.routeSpeech('linn', 'again', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'again', body('body:ears')), null); assert.equal(openCalls.length, 1); assert.equal(publishCalls.length, 0); assert.equal(failures.length, 2); @@ -1380,11 +1377,11 @@ test("an open's answer whose channel names no id is this channel's, so its inbou registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); setOpenResult({ channel: { direction: 'inbound' } }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:ears')), null); assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'speech opened the channel'); assert.equal(publishCalls.length, 0, 'nothing was published'); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); - assert.equal(registry.isChannelOpen('body:ears'), true, 'the open stands'); + assert.equal(registry.isChannelOpen(body('body:ears')), true, 'the open stands'); }); test("an open's answer that declares no direction, or one that isn't inbound, leaves speech published (R1-3)", async () => { @@ -1397,7 +1394,7 @@ test("an open's answer that declares no direction, or one that isn't inbound, le const { registry, publishCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); setOpenResult(answer); - assert.deepEqual(await registry.routeSpeech('linn', 'hello', 'body:ears'), { delivered: true, serverId: 'body', channelId: 'body:ears' }, + assert.deepEqual(await registry.routeSpeech('linn', 'hello', body('body:ears')), { delivered: true, serverId: 'body', channelId: 'body:ears' }, `published after the answer ${JSON.stringify(answer)}`); assert.equal(publishCalls.length, 1); assert.equal(failures.length, 0); @@ -1416,7 +1413,7 @@ test('a channel removed while the host opens it for speech is refused, and nothi await registry.handleChanged('body', { removed: ['body:home'] } as never); }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:home')), null); assert.deepEqual(openCalls.map((c) => c.channelId), ['body:home']); assert.equal(publishCalls.length, 0, 'nothing was published to the removed channel'); assert.deepEqual(failures.map((f) => f.reason), ['opening the channel for these words didn\'t complete: the "body" server no longer lists the channel'], @@ -1431,7 +1428,7 @@ test("a server's refusal of an open or a tool's publish reaches the agent in its channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], } as never); setOnOpen(async () => { throw refusal(); }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:home')), null); assert.deepEqual(failures.map((f) => f.reason), ['opening the channel for these words didn\'t complete: The "body" server answered with an error: Channel unavailable']); const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, { kind: 'agent', agentName: 'linn' }); assert.equal(opened.error, 'Opening channel "body:home" didn\'t complete; the open stays recorded, and this host tries it again when the ' + @@ -1452,7 +1449,7 @@ test("an open's answer can't lift an inbound-only declaration: a server lifts it setOpenResult({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }); const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:ears' }, { kind: 'agent', agentName: 'linn' }); assert.equal(opened.success, true); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null, 'still refused after the answer'); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:ears')), null, 'still refused after the answer'); assert.equal(publishCalls.length, 0); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared/); @@ -1460,7 +1457,7 @@ test("an open's answer can't lift an inbound-only declaration: a server lifts it await registry.handleChanged('body', { updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); - assert.deepEqual(await registry.routeSpeech('linn', 'now', 'body:ears'), { delivered: true, serverId: 'body', channelId: 'body:ears' }); + assert.deepEqual(await registry.routeSpeech('linn', 'now', body('body:ears')), { delivered: true, serverId: 'body', channelId: 'body:ears' }); assert.equal(publishCalls.length, 1); }); @@ -1491,7 +1488,7 @@ test("an open's answer that arrives in the same read as a newer channels/changed await registry.handleChanged('body', { added: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); - const delivery = registry.routeSpeech('linn', 'hello', 'body:ears'); + const delivery = registry.routeSpeech('linn', 'hello', body('body:ears')); await opening; answerOpen({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }); void registry.handleChanged('body', { @@ -1515,7 +1512,7 @@ test("an open by reconcile applies its answer too: a channel the answer declares await registry.handleRegister('body', { channels: [ears] } as never); assert.equal(openCalls.length, 2, 'reconcile opened it again'); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.equal(await registry.routeSpeech('linn', 'hello', body('body:ears')), null); assert.equal(publishCalls.length, 0); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared/); }); @@ -1534,10 +1531,10 @@ async function withHome() { test("a reconcile's open answered while the channel is tuned out is recorded: a tune-out keeps the transport open", async () => { const { registry, home, opens } = await withHome(); - assert.equal(registry.isChannelOpen('body:home'), false); + assert.equal(registry.isChannelOpen(body('body:home')), false); (registry as unknown as { setDesiredState(s: string, c: string, state: string, source: string): void }) .setDesiredState('body', 'body:home', 'tuned-out', 'agent-tool'); await registry.handleChanged('body', { added: [home] } as never); assert.equal(opens(), 1, "the announcement's reconcile opened it"); - assert.equal(registry.isChannelOpen('body:home'), true); + assert.equal(registry.isChannelOpen(body('body:home')), true); }); diff --git a/test/channel-registry-typing.test.ts b/test/channel-registry-typing.test.ts index 1d49067f0..20ae63596 100644 --- a/test/channel-registry-typing.test.ts +++ b/test/channel-registry-typing.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant } 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'; @@ -42,7 +42,7 @@ function makeRegistry() { // channel-incoming event plumbing, which isn't under test here. (registry as unknown as { channels: Map; - }).channels.set('srv:ch1', { + }).channels.set(destinationKey({ serverId: 'srv', channelId: 'ch1' }), { serverId: 'srv', descriptor: { id: 'ch1' }, open: true, @@ -123,10 +123,19 @@ test('startTyping with no metadata mid-typing does not dispatch', () => { test('stopTyping() global branch only dispatches stops for channels with intervals', () => { const { registry, calls } = makeRegistry(); - // Stash metadata without an interval — simulates a past start that missed - // findChannelEntry (pre-registration race). + // A registered channel with metadata and no interval, a state no start + // leaves now (a start keeps metadata beside its interval), stands in for + // metadata outliving its interval: the global stop dispatches only for + // channels with intervals, so it sends this one nothing. + (registry as unknown as { + channels: Map; + }).channels.set(destinationKey({ serverId: 'srv', channelId: 'phantom-ch' }), { + serverId: 'srv', + descriptor: { id: 'phantom-ch' }, + open: true, + }); (registry as unknown as { typingMetadata: Map> }).typingMetadata.set( - 'phantom-ch', + destinationKey({ serverId: 'srv', channelId: 'phantom-ch' }), { foo: 1 }, ); diff --git a/test/channel-tool-words.test.ts b/test/channel-tool-words.test.ts index 3d9d9efbe..be9c12549 100644 --- a/test/channel-tool-words.test.ts +++ b/test/channel-tool-words.test.ts @@ -20,8 +20,7 @@ import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; interface Internals { channelRegistry: unknown; agents: Map; - conversationRouter?: { touch(channelId: string, now?: number): void; getBinding(channelId: string): unknown }; - conversationAgentHomes: Map; + conversationRouter?: { touch(channel: { serverId: string; channelId: string }, now?: number): void; getBinding(channel: { serverId: string; channelId: string }): unknown }; lastConversationSweep: number; sweepExpiredConversations(): void; getToolsForAgent(agentName: string): Array<{ name: string; description?: string }>; @@ -138,13 +137,13 @@ describe("channel_open and channel_close's words follow whether the agent's wind assert.deepEqual(words(internals(framework).getToolsForAgent(fork)), HOLDER); // The idle sweep unbinds a fork before its closure turn. Its words don't change for that one turn, which would // send its whole window uncached: the closure turn's request carries the registry's. - internals(framework).conversationRouter!.touch('slack:C1', 0); + internals(framework).conversationRouter!.touch({ serverId: 'srv', channelId: 'slack:C1' }, 0); internals(framework).lastConversationSweep = 0; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'bye' }])); const before = membrane.calls.length; internals(framework).sweepExpiredConversations(); await framework.runUntilIdle(); - assert.equal(internals(framework).conversationRouter!.getBinding('slack:C1'), undefined, 'the sweep unbound the home'); + assert.equal(internals(framework).conversationRouter!.getBinding({ serverId: 'srv', channelId: 'slack:C1' }), undefined, 'the sweep unbound the home'); const sent = membrane.calls.slice(before) as unknown as Array<{ tools?: Array<{ name: string; description?: string }> }>; assert.equal(sent.length, 1, 'the closure turn was the one request'); assert.deepEqual(words(sent[0]!.tools ?? []), HOLDER); diff --git a/test/code-execution-isolate-scripts.test.ts b/test/code-execution-isolate-scripts.test.ts index f2bc686ab..0b2b1952f 100644 --- a/test/code-execution-isolate-scripts.test.ts +++ b/test/code-execution-isolate-scripts.test.ts @@ -284,8 +284,8 @@ test("each call carries the script's origin, and the watching call's turn only w assert.deepEqual( contexts.map((c) => { const { watching: _, ...rest } = c as { watching?: unknown }; return rest; }), [ - { name: 'echo', origin: 'origin-1', watch }, - { name: 'slow', origin: 'origin-1', watch }, + { name: 'echo', origin: 'origin-1', watch, startTurnId: 7 }, + { name: 'slow', origin: 'origin-1', watch, startTurnId: 7 }, ], ); assert.equal((contexts[1] as { watching(): unknown }).watching(), watch); @@ -297,6 +297,32 @@ test("each call carries the script's origin, and the watching call's turn only w await scripts.dispose(); }); +test("a script's calls carry the turn its run was made in, under a later turn's wait too", async () => { + const contexts: Array<{ name: string; watch?: { turnId?: number }; startTurnId?: number }> = []; + const gates: Array<() => void> = []; + const { scripts } = setup({ + config: { yieldMs: 30_000 }, + host: { + dispatch: async (_agent, name, args, context) => { + contexts.push({ name, ...context }); + if (name === 'slow') await new Promise((resolve) => gates.push(resolve)); + return result(`${name} ${args}`); + }, + }, + }); + // The run returns at once, and the script goes on; a later turn waits on it. + const run = await scripts.run('alice', `await tools.slow({}); await tools.echo({});`, 0, { watch: { turnId: 7, endTurn: false } }); + for (let i = 0; i < 2_000 && gates.length === 0; i++) await new Promise((r) => setTimeout(r, 10)); + const waiting = scripts.wait('alice', run.scriptId, 30_000, { turnId: 9, endTurn: false }); + gates[0]!(); + const r = await waiting; + assert.deepEqual(r.ending, { how: 'finished' }); + const echo = contexts.find((c) => c.name === 'echo'); + assert.equal(echo?.watch?.turnId, 9, 'the call carries the turn watching it'); + assert.equal(echo?.startTurnId, 7, 'and the turn its script was started in'); + await scripts.dispose(); +}); + test("a script's code is read only by its own agent", async () => { const { scripts } = setup({ config: { yieldMs: 100 } }); const code = `await tools.slow({ key: 'mine' });`; diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index c4bcc75b7..0471ac3c9 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -1224,19 +1224,20 @@ describe('a round whose run or wait call watches a script that can change its ro // What each `>>` label names; a test can rename one while a script runs. const directory: Record = { '#alpha': 'chan-alpha' }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', - resolveProseTarget: (spec: string) => (directory[spec] ? { channelId: directory[spec] } : { error: `no channel ${spec}` }), - routeSpeech: async (_agent: string, text: string, channelId: string | null) => { - if (!channelId) return null; - log.push(`publish:${text}@${channelId}`); - return { delivered: true, serverId: 'stub', channelId }; + // Every channel is the stub's server's: the framework names each with it. + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), + resolveProseTarget: (spec: string) => (directory[spec] ? { serverId: 'stub', channelId: directory[spec] } : { error: `no channel ${spec}` }), + routeSpeech: async (_agent: string, text: string, to: { channelId: string } | null) => { + if (!to) return null; + log.push(`publish:${text}@${to.channelId}`); + return { delivered: true, serverId: 'stub', channelId: to.channelId }; }, // A named or sticky destination posts through deliverSpeech, as with // the real registry, which reports the post's outcome. - deliverSpeech: async (_agent: string, text: string, channelId: string | null) => { - if (!channelId) return { status: 'failed', reason: 'no channel', at: Date.now() }; - log.push(`publish:${text}@${channelId}`); - return { status: 'delivered', destination: { serverId: 'stub', channelId }, at: Date.now() }; + deliverSpeech: async (_agent: string, text: string, to: { channelId: string } | null) => { + if (!to) return { status: 'failed', reason: 'no channel', at: Date.now() }; + log.push(`publish:${text}@${to.channelId}`); + return { status: 'delivered', destination: { serverId: 'stub', channelId: to.channelId }, at: Date.now() }; }, resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), // Each delivery hands its stream the destination its publish went to, @@ -1249,7 +1250,7 @@ describe('a round whose run or wait call watches a script that can change its ro log.push(`tool:${name}`); if (name === 'skip_reply') return { success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }; // A successful channel_open moves the turn's locus, as the real one does. - if (name === 'channel_open') return { success: true, data: { channelId: input?.channelId } }; + if (name === 'channel_open') return { success: true, data: { channelId: input?.channelId, serverId: 'stub' } }; return { success: true, data: {} }; }, getChannelTools: () => CHANNEL_TOOLS, diff --git a/test/conversation-fork-injection-scoping.test.ts b/test/conversation-fork-injection-scoping.test.ts index a20790a2b..94a24ea39 100644 --- a/test/conversation-fork-injection-scoping.test.ts +++ b/test/conversation-fork-injection-scoping.test.ts @@ -14,7 +14,7 @@ import type { ToolDefinition, ToolResult, } from '../src/index.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey, type ChannelRef } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; @@ -22,7 +22,7 @@ const FORK_AGENT = 'conversation-chanA-g1'; type FrameworkInternals = { channelRegistry: ChannelRegistry | null; - conversationAgentHomes: Map; + conversationAgentHomes: Map; hookOrchestrator: { beforeInference(params: unknown): Promise; emitLifecycle(params: unknown): void; @@ -93,7 +93,7 @@ function installChannelRegistry( for (const channelId of channelIds) { // §14.5: register first (a conforming server's channels/register), // then the incoming message marks the registered channel open. - (registry as unknown as { channels: Map }).channels.set(`srv:${channelId}`, { + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'srv', channelId }), { serverId: 'srv', descriptor: { id: channelId, type: 'srv', label: channelId }, open: false, }); registry.handleIncoming('srv', { @@ -111,7 +111,7 @@ function installChannelRegistry( } function bindForkHome(framework: AgentFramework, agentName = FORK_AGENT, channelId = 'chanA'): void { - frameworkInternals(framework).conversationAgentHomes.set(agentName, channelId); + frameworkInternals(framework).conversationAgentHomes.set(agentName, { serverId: 'srv', channelId }); } function installHookInjections(framework: AgentFramework, injections: ContextInjection[]): void { @@ -176,6 +176,9 @@ describe('conversation fork injection scoping', () => { systemInjection('module chanB context', 'chanB'), systemInjection('module retrieval context', 'retrieval'), systemInjection('module unnamespaced context'), + // A namespace that is a registered channel's server-qualified name, not + // its id: channel context is namespaced by id, so it's kept. + systemInjection('module qualified-name context', 'srv:chanB'), ], [FORK_AGENT]); installChannelRegistry(framework); bindForkHome(framework); @@ -194,6 +197,7 @@ describe('conversation fork injection scoping', () => { assertOmits(system, 'module chanB context'); assertContains(system, 'module retrieval context'); assertContains(system, 'module unnamespaced context'); + assertContains(system, 'module qualified-name context'); assertContains(system, 'hook chanA context'); assertOmits(system, 'hook chanB context'); assertContains(system, 'hook retrieval context'); @@ -277,7 +281,7 @@ describe('conversation fork injection scoping', () => { ], []); const registry = installChannelRegistry(framework); // chanC is registered and was never opened. - (registry as unknown as { channels: Map }).channels.set('srv:chanC', { + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'srv', channelId: 'chanC' }), { serverId: 'srv', descriptor: { id: 'chanC', type: 'srv', label: 'chanC' }, open: false, }); // The server's transport closes: chanA and chanB are unconfirmed now. diff --git a/test/conversation-router.test.ts b/test/conversation-router.test.ts index 1a055d4f8..bfaa3e108 100644 --- a/test/conversation-router.test.ts +++ b/test/conversation-router.test.ts @@ -52,14 +52,14 @@ test('spawn decision does not create the binding until bind() is called', () => const router = makeRouter(); const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d1.kind, 'spawn'); - assert.equal(router.getBinding('slack:C1'), undefined); + assert.equal(router.getBinding({ serverId: '', channelId: 'slack:C1' }), undefined); // Framework failed to spawn → next mention proposes the same generation. const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d2.kind, 'spawn'); if (d2.kind === 'spawn') assert.equal(d2.generation, 1); - if (d2.kind === 'spawn') router.bind('slack:C1', d2.agentName, d2.generation, T0 + 1); + if (d2.kind === 'spawn') router.bind({ serverId: '', channelId: 'slack:C1' }, d2.agentName, d2.generation, T0 + 1); const d3 = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }); assert.equal(d3.kind, 'existing'); }); @@ -70,7 +70,7 @@ test('spawn decision does not create the binding until bind() is called', () => test('bound channel: non-mentions land without triggering, mentions trigger', () => { const router = makeRouter(); - router.bind('slack:C1', 'conversation-slack-C1-g1', 1, T0); + router.bind({ serverId: '', channelId: 'slack:C1' }, 'conversation-slack-C1-g1', 1, T0); const ambient = router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }); assert.equal(ambient.kind, 'existing'); @@ -83,7 +83,7 @@ test('bound channel: non-mentions land without triggering, mentions trigger', () test('bound DM: every message triggers', () => { const router = makeRouter(); - router.bind('slack:D1', 'conversation-slack-D1-g1', 1, T0); + router.bind({ serverId: '', channelId: 'slack:D1' }, 'conversation-slack-D1-g1', 1, T0); const decision = router.route({ channelId: 'slack:D1', mentioned: false, kind: 'dm' }); assert.equal(decision.kind, 'existing'); if (decision.kind === 'existing') assert.equal(decision.trigger, true); @@ -95,25 +95,25 @@ test('bound DM: every message triggers', () => { test('routing queries leave the binding and its expiry clock unchanged', () => { const router = makeRouter({ idleTtlMs: 1000 }); - router.bind('slack:C1', 'fork-a', 3, T0); - const before = { ...router.getBinding('slack:C1')! }; + router.bind({ serverId: '', channelId: 'slack:C1' }, 'fork-a', 3, T0); + const before = { ...router.getBinding({ serverId: '', channelId: 'slack:C1' })! }; const generations = router.exportGenerations(); assert.equal(router.route({ channelId: 'slack:C1', mentioned: false, kind: 'channel' }).kind, 'existing'); assert.equal(router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }).kind, 'existing'); - assert.deepEqual(router.getBinding('slack:C1'), before, 'previewing a route must not prolong an engagement'); + assert.deepEqual(router.getBinding({ serverId: '', channelId: 'slack:C1' }), before, 'previewing a route must not prolong an engagement'); assert.deepEqual(router.exportGenerations(), generations); assert.equal(router.expired(T0 + 1001).length, 1); }); test('touch() refreshes expiry explicitly and leaves unbound channels alone', () => { const router = makeRouter({ idleTtlMs: 1000 }); - router.bind('slack:C1', 'conversation-slack-C1-g1', 1, T0); + router.bind({ serverId: '', channelId: 'slack:C1' }, 'conversation-slack-C1-g1', 1, T0); // Delivery records activity independently of the routing query. - router.touch('slack:C1', T0 + 900); - router.touch('slack:unknown', T0 + 900); - assert.equal(router.getBinding('slack:unknown'), undefined); + router.touch({ serverId: '', channelId: 'slack:C1' }, T0 + 900); + router.touch({ serverId: '', channelId: 'slack:unknown' }, T0 + 900); + assert.equal(router.getBinding({ serverId: '', channelId: 'slack:unknown' }), undefined); assert.deepEqual(router.exportGenerations(), { 'slack:C1': 1 }); assert.deepEqual(router.expired(T0 + 1500), []); assert.equal(router.expired(T0 + 1901).length, 1); @@ -123,9 +123,9 @@ test('rebind after expiry gets a fresh generation and agent name', () => { const router = makeRouter({ idleTtlMs: 1000 }); const d1 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); if (d1.kind !== 'spawn') assert.fail('expected spawn'); - router.bind('slack:C1', d1.agentName, d1.generation, T0); + router.bind({ serverId: '', channelId: 'slack:C1' }, d1.agentName, d1.generation, T0); - router.unbind('slack:C1'); // the framework does this as the closure turn begins, just before starting it (sweepExpiredConversations) + router.unbind({ serverId: '', channelId: 'slack:C1' }); // the framework does this as the closure turn begins, just before starting it (sweepExpiredConversations) const d2 = router.route({ channelId: 'slack:C1', mentioned: true, kind: 'channel' }); assert.equal(d2.kind, 'spawn'); @@ -179,8 +179,8 @@ test('a fork name starts with the configured agentPrefix', () => { test('channelForAgent reverse lookup', () => { const router = makeRouter(); - router.bind('slack:C1', 'fork-a', 1, T0); - assert.equal(router.channelForAgent('fork-a'), 'slack:C1'); + router.bind({ serverId: '', channelId: 'slack:C1' }, 'fork-a', 1, T0); + assert.deepEqual(router.channelForAgent('fork-a'), { serverId: '', channelId: 'slack:C1' }); assert.equal(router.channelForAgent('fork-b'), undefined); }); @@ -223,8 +223,8 @@ test('group DMs bind on any message but trigger only on mention by default', () test('generation counters export/hydrate round-trips and never regresses', () => { const router = new ConversationRouter({ templateAgent: 'main' }); - router.bind('slack:C1', 'conversation-slack-C1-g3', 3, T0); - router.bind('slack:D1', 'conversation-slack-D1-g1', 1, T0); + router.bind({ serverId: '', channelId: 'slack:C1' }, 'conversation-slack-C1-g3', 3, T0); + router.bind({ serverId: '', channelId: 'slack:D1' }, 'conversation-slack-D1-g1', 1, T0); const exported = router.exportGenerations(); assert.deepEqual(exported, { 'slack:C1': 3, 'slack:D1': 1 }); @@ -235,9 +235,44 @@ test('generation counters export/hydrate round-trips and never regresses', () => assert.equal((decision as { generation: number }).generation, 4, 'restart must not reuse generation names'); // Hydration never regresses a counter that advanced in the meantime. - restored.bind('slack:D1', 'conversation-slack-D1-g5', 5, T0); + restored.bind({ serverId: '', channelId: 'slack:D1' }, 'conversation-slack-D1-g5', 5, T0); restored.hydrateGenerations(exported); const d1 = restored.route({ channelId: 'slack:D2', mentioned: false, kind: 'dm' }); assert.equal(d1.kind, 'spawn', 'sanity'); assert.equal(restored.exportGenerations()['slack:D1'], 5); }); + +test('bindings are per server: two servers that share a channel id bind a fork each', () => { + // Channel ids are unique only within one MCPL server's connection. + const router = makeRouter(); + router.bind({ serverId: 'a', channelId: 'general' }, 'fork-a', 1, T0); + assert.equal(router.route({ channelId: 'general', serverId: 'a', mentioned: false, kind: 'channel' }).kind, 'existing'); + assert.equal(router.route({ channelId: 'general', serverId: 'b', mentioned: true, kind: 'channel' }).kind, 'spawn', + "b's channel isn't a's binding"); + router.bind({ serverId: 'b', channelId: 'general' }, 'fork-b', 2, T0); + assert.deepEqual(router.channelForAgent('fork-b'), { serverId: 'b', channelId: 'general' }); + assert.equal(router.getBinding({ serverId: 'b', channelId: 'general' })?.serverId, 'b'); + router.unbind({ serverId: 'a', channelId: 'general' }); + assert.equal(router.getBinding({ serverId: 'a', channelId: 'general' }), undefined); + assert.equal(router.getBinding({ serverId: 'b', channelId: 'general' })?.agentName, 'fork-b', "unbinding a's leaves b's"); +}); + +test("a shared id's fork generation never goes back down when one server's spawn binds after another's", () => { + // The counter is per bare id, and two servers' spawns run beside each + // other: `a`'s spawn of generation 3 binds after `b` has bound 4. + const router = makeRouter(); + router.bind({ serverId: 'b', channelId: 'general' }, 'fork-b4', 4, T0); + router.bind({ serverId: 'a', channelId: 'general' }, 'fork-a3', 3, T0); + assert.deepEqual(router.exportGenerations(), { general: 4 }); + const next = router.route({ channelId: 'general', serverId: 'c', mentioned: true, kind: 'channel' }); + assert.equal((next as { generation?: number }).generation, 5, "a later spawn doesn't reuse generation 4's name"); +}); + +test("touch() refreshes one server's binding of a shared id, not the other's", () => { + const router = makeRouter({ idleTtlMs: 1000 }); + router.bind({ serverId: 'a', channelId: 'general' }, 'fork-a', 1, T0); + router.bind({ serverId: 'b', channelId: 'general' }, 'fork-b', 2, T0); + router.touch({ serverId: 'b', channelId: 'general' }, T0 + 900); + assert.deepEqual(router.expired(T0 + 1500).map((b) => b.agentName), ['fork-a'], "a's binding idles out on its own clock"); + assert.deepEqual(router.expired(T0 + 1901).map((b) => b.agentName).sort(), ['fork-a', 'fork-b']); +}); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index d30c2a752..5efb897aa 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -24,6 +24,7 @@ const forkName = (channelId: string, generation: number) => forkAgentName('conve function incomingEvent(overrides: { channelId: string; + serverId?: string; text: string; mentioned?: boolean; channelType?: string; @@ -31,7 +32,7 @@ function incomingEvent(overrides: { }): ProcessEvent { return { type: 'mcpl:channel-incoming', - serverId: 'srv', + serverId: overrides.serverId ?? 'srv', channelId: overrides.channelId, messageId: overrides.messageId ?? `m-${Math.random().toString(36).slice(2)}`, author: { id: 'U1', name: 'alice' }, @@ -86,7 +87,7 @@ describe('Conversation routing', () => { /** Make a binding look idle past the TTL and re-arm the sweep throttle — * deterministic expiry instead of racing a 1ms TTL against the wall clock. */ function forceExpiry(framework: AgentFramework, channelId: string, idleTtlMs: number) { - const binding = framework.getConversationRouter()!.getBinding(channelId); + const binding = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId }); assert.ok(binding, `binding for ${channelId} should exist before forcing expiry`); binding!.lastActivity = Date.now() - idleTtlMs - 1_000; (framework as unknown as { lastConversationSweep: number }).lastConversationSweep = 0; @@ -192,19 +193,64 @@ describe('Conversation routing', () => { // Binding is live. const router = framework.getConversationRouter()!; - assert.equal(router.getBinding('slack:D1')?.agentName, forkName('slack:D1', 1)); + assert.equal(router.getBinding({ serverId: 'srv', channelId: 'slack:D1' })?.agentName, forkName('slack:D1', 1)); await framework.stop(); }); + it("two servers' channels with one id each get a fork of their own", async () => { + // Channel ids are unique only within one MCPL server's connection. The + // mock hands every queued response to the next stream, so each wake gets + // its own response, queued just before it. + const framework = await makeFramework(); + const spawned: Array<{ serverId?: string; channelId?: string }> = []; + framework.onTrace((e) => { + const t = e as { type: string; serverId?: string; channelId?: string }; + if (t.type === 'mcpl:conversation-spawned') spawned.push({ serverId: t.serverId, channelId: t.channelId }); + }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }])); + framework.pushEvent(incomingEvent({ channelId: 'slack:D1', text: 'from srv', channelType: 'im' })); + await framework.runUntilIdle(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'two' }])); + framework.pushEvent(incomingEvent({ serverId: 'other', channelId: 'slack:D1', text: 'from other', channelType: 'im' })); + await framework.runUntilIdle(); + + const router = framework.getConversationRouter()!; + // The counter is kept per id, so the second server's fork takes the next generation. + assert.equal(router.getBinding({ serverId: 'srv', channelId: 'slack:D1' })?.agentName, forkName('slack:D1', 1)); + assert.equal( + router.getBinding({ serverId: 'other', channelId: 'slack:D1' })?.agentName, + forkAgentName('conversation', 'other', 'slack:D1', 2), + ); + assert.deepEqual(router.channelForAgent(forkName('slack:D1', 1)), { serverId: 'srv', channelId: 'slack:D1' }); + const otherFork = framework.getAgent(forkAgentName('conversation', 'other', 'slack:D1', 2))!; + const { messages } = await otherFork.getContextManager().compile(); + assert.ok(messages.some((m) => JSON.stringify(m.content).includes('from other')), "the other server's message reaches its own fork"); + assert.ok(!messages.some((m) => JSON.stringify(m.content).includes('from srv')), "and not srv's"); + // Each spawned fork's home keeps its server, which its channel tools and + // speech go to (shared-channel-id's fork tests, over recording servers). + const homes = (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes; + assert.deepEqual(homes.get(forkName('slack:D1', 1)), { serverId: 'srv', channelId: 'slack:D1' }); + assert.deepEqual(homes.get(otherFork.name), { serverId: 'other', channelId: 'slack:D1' }); + assert.deepEqual(spawned, [{ serverId: 'srv', channelId: 'slack:D1' }, { serverId: 'other', channelId: 'slack:D1' }], + 'each spawn is traced with its server'); + await framework.stop(); + }); + it('channel message without mention is dropped; mention spawns', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'On it.' }])); const framework = await makeFramework(); + const unrouted: Array = []; + framework.onTrace((e) => { + const t = e as { type: string; serverId?: string }; + if (t.type === 'mcpl:conversation-unrouted') unrouted.push(t.serverId); + }); framework.pushEvent(incomingEvent({ channelId: 'slack:C1', text: 'just chatting' })); await framework.runUntilIdle(); assert.equal(framework.getAgent(forkName('slack:C1', 1)), null, 'no fork without mention'); assert.equal(membrane.calls.length, 0, 'no inference for unrouted messages'); + assert.deepEqual(unrouted, ['srv'], "the unrouted trace names the message's server"); framework.pushEvent(incomingEvent({ channelId: 'slack:C1', text: 'bot, help', mentioned: true })); await framework.runUntilIdle(); @@ -612,7 +658,7 @@ describe('Conversation routing', () => { try { await deliver(framework, 'slack:C1', 'first'); const router = framework.getConversationRouter()!; - const binding = router.getBinding('slack:C1')!; + const binding = router.getBinding({ serverId: 'srv', channelId: 'slack:C1' })!; binding.lastActivity = 1; const before = Date.now(); framework.pushEvent({ @@ -634,7 +680,7 @@ describe('Conversation routing', () => { try { await deliver(framework, 'slack:C1', 'first'); const router = framework.getConversationRouter()!; - const binding = router.getBinding('slack:C1')!; + const binding = router.getBinding({ serverId: 'srv', channelId: 'slack:C1' })!; binding.lastActivity = 1; const context = framework.getAgent(binding.agentName)!.getContextManager(); const addMessage = context.addMessage; @@ -655,7 +701,7 @@ describe('Conversation routing', () => { try { await deliver(framework, 'slack:C1', 'first'); const router = framework.getConversationRouter()!; - const binding = router.getBinding('slack:C1')!; + const binding = router.getBinding({ serverId: 'srv', channelId: 'slack:C1' })!; binding.lastActivity = 1; const before = Date.now(); await deliver(framework, 'slack:C1', 'updated detail', binding.agentName); @@ -672,10 +718,10 @@ describe('Conversation routing', () => { try { await deliver(framework, 'slack:C1', 'first'); const router = framework.getConversationRouter()!; - const oldName = router.getBinding('slack:C1')!.agentName; - router.unbind('slack:C1'); + const oldName = router.getBinding({ serverId: 'srv', channelId: 'slack:C1' })!.agentName; + router.unbind({ serverId: 'srv', channelId: 'slack:C1' }); await deliver(framework, 'slack:C1', 'new engagement'); - const binding = router.getBinding('slack:C1')!; + const binding = router.getBinding({ serverId: 'srv', channelId: 'slack:C1' })!; binding.lastActivity = 1; await deliver(framework, 'slack:C1', 'old engagement correction', oldName); @@ -691,11 +737,17 @@ describe('Conversation routing', () => { const IDLE_TTL_MS = 60_000; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello!' }])); const framework = await makeFramework({ idleTtlMs: IDLE_TTL_MS }); + // The lifecycle traces name the channel's server, as the spawn's does. + const lifecycle: Array<[string, string | undefined]> = []; + framework.onTrace((e) => { + const t = e as { type: string; serverId?: string }; + if (t.type === 'mcpl:conversation-closed' || t.type === 'mcpl:conversation-disposed') lifecycle.push([t.type, t.serverId]); + }); framework.pushEvent(incomingEvent({ channelId: 'slack:D3', text: 'hi', channelType: 'im' })); await framework.runUntilIdle(); const router = framework.getConversationRouter()!; - assert.ok(router.getBinding('slack:D3'), 'spawn should leave a live binding'); + assert.ok(router.getBinding({ serverId: 'srv', channelId: 'slack:D3' }), 'spawn should leave a live binding'); const g1 = framework.getAgent(forkName('slack:D3', 1)); assert.ok(g1, 'g1 fork should exist after spawn'); @@ -707,12 +759,13 @@ describe('Conversation routing', () => { framework.pushEvent({ type: 'external-message', source: 'test', content: 'tick', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); - assert.equal(router.getBinding('slack:D3'), undefined, 'binding should be gone after TTL'); + assert.equal(router.getBinding({ serverId: 'srv', channelId: 'slack:D3' }), undefined, 'binding should be gone after TTL'); const { messages } = await g1!.getContextManager().compile(); assert.ok( messages.some((m) => JSON.stringify(m.content).includes('engagement is closing')), 'closure prompt should be in the fork context', ); + assert.deepEqual(lifecycle, [['mcpl:conversation-closed', 'srv'], ['mcpl:conversation-disposed', 'srv']]); assert.equal( framework.getAgent(forkName('slack:D3', 1)), null, 'closed fork should be disposed once its closure turn finished', @@ -743,19 +796,19 @@ describe('Conversation routing', () => { it("a disposed conversation agent's name is refused for a new agent", async () => { const framework = await makeFramework(); const internals = framework as unknown as { - createConversationAgent(name: string, channelId: string): Promise; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise; disposeConversationAgent(name: string): void; }; const name = forkName('slack:D9', 1); try { - await internals.createConversationAgent(name, 'slack:D9'); + await internals.createConversationAgent(name, { serverId: 'srv', channelId: 'slack:D9' }); assert.ok(framework.getAgent(name), 'the first agent is registered under the name'); internals.disposeConversationAgent(name); assert.equal(framework.getAgent(name), null, 'the agent is gone, so only its name is held'); // Twice: a refusal that released the name would let the second through. for (const attempt of [1, 2]) { await assert.rejects( - internals.createConversationAgent(name, 'slack:D9'), + internals.createConversationAgent(name, { serverId: 'srv', channelId: 'slack:D9' }), new RegExp(`Conversation agent name "${name}" is already registered or reserved`), `attempt ${attempt}`, ); @@ -1134,8 +1187,8 @@ describe('Conversation routing', () => { // Both closed, with no closure inference left to run (the case the // reaper is for), and the one whose removal fails comes first. const router = framework.getConversationRouter()!; - router.unbind('slack:D10'); - router.unbind('slack:D11'); + router.unbind({ serverId: 'srv', channelId: 'slack:D10' }); + router.unbind({ serverId: 'srv', channelId: 'slack:D11' }); const internals = framework as unknown as { closingConversationAgents: Set; lastConversationSweep: number }; internals.closingConversationAgents.add(failing); internals.closingConversationAgents.add(next); @@ -1214,8 +1267,8 @@ describe('Conversation routing', () => { assert.deepEqual(failed, [], 'neither spawn fails'); const router = framework.getConversationRouter()!; - const spaced = router.getBinding('foo bar')?.agentName; - const dashed = router.getBinding('foo-bar')?.agentName; + const spaced = router.getBinding({ serverId: 'srv', channelId: 'foo bar' })?.agentName; + const dashed = router.getBinding({ serverId: 'srv', channelId: 'foo-bar' })?.agentName; assert.ok(spaced && dashed, 'each channel is bound to a fork'); assert.notEqual(spaced, dashed, 'to forks of its own'); const held = (name: string) => framework.getAgent(name)!.getContextManager().getAllMessages() @@ -1233,7 +1286,7 @@ describe('Conversation routing', () => { ]); fw1.pushEvent(incomingEvent({ channelId: 'foo bar', text: 'PRIVATE to foo bar', channelType: 'im' })); await fw1.runUntilIdle(); - const first = fw1.getConversationRouter()!.getBinding('foo bar')?.agentName; + const first = fw1.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'foo bar' })?.agentName; assert.ok(first, 'the first channel spawns its fork'); await fw1.stop(); @@ -1242,7 +1295,7 @@ describe('Conversation routing', () => { fw2.pushEvent(incomingEvent({ channelId: 'foo-bar', text: 'hello from foo-bar', channelType: 'im' })); await fw2.runUntilIdle(); - const name = fw2.getConversationRouter()!.getBinding('foo-bar')?.agentName; + const name = fw2.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'foo-bar' })?.agentName; assert.ok(name, 'the second channel spawns its fork'); assert.notEqual(name, first, "under a name, and so in a namespace, other than the first channel's"); const contents = fw2.getAgent(name!)!.getContextManager().getAllMessages().map((m) => JSON.stringify(m.content)); @@ -1274,7 +1327,7 @@ describe('Conversation routing', () => { assert.equal(failures.length, 2, 'both foreign channel operations should be rejected'); assert.ok(failures[0]!.error.includes("so it can't open other channels, and nothing was opened"), 'channel_open is rejected outright'); - assert.ok(failures[1]!.error.includes('closing slack:C8 is not allowed'), 'foreign channel_close is rejected'); + assert.ok(failures[1]!.error.includes('closing "slack:C8" is not allowed'), 'foreign channel_close is rejected'); await framework.stop(); }); @@ -1310,7 +1363,15 @@ describe('Conversation routing', () => { // channel_* tools). Stub the downstream boundary — the unit under test is // the fence ABOVE it, and a home-defaulted publish must get past the fence // to be observable at tool:started. - fw.channelRegistry = { handleChannelToolCall: async () => ({ success: true }), stopAll: () => {} }; + fw.channelRegistry = { + handleChannelToolCall: async () => ({ success: true }), + stopAll: () => {}, + // The fence names the home channel as the agent would see it, and + // reads whether its server still registers it. + nameShown: (channel: { channelId: string }) => channel.channelId, + quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), + getDescriptor: () => ({}), + } as typeof fw.channelRegistry; // (a) omitted channelId → rewritten to the home channel, passes the fence fw.dispatchChannelToolCall(fork, { id: 'p1', name: 'channel_publish', input: { content: 'hello home' } }); @@ -1326,7 +1387,7 @@ describe('Conversation routing', () => { fw.dispatchChannelToolCall(fork, { id: 'p2', name: 'channel_publish', input: { channelId: 'slack:C8', content: 'sneaky' } }); const rejection = failures.find((f) => f.callId === 'p2'); assert.ok(rejection, 'foreign publish should be rejected'); - assert.ok(rejection!.error.includes('publishing to slack:C8 is not allowed'), 'rejection names the foreign channel'); + assert.ok(rejection!.error.includes('publishing to "slack:C8" is not allowed'), 'rejection names the foreign channel'); assert.equal( started.filter((t) => t.tool === 'channel_publish').length, 1, 'foreign publish never reaches tool:started', diff --git a/test/deferred-wake-integrity.test.ts b/test/deferred-wake-integrity.test.ts index f6e65e71b..5c79b786e 100644 --- a/test/deferred-wake-integrity.test.ts +++ b/test/deferred-wake-integrity.test.ts @@ -118,9 +118,9 @@ class DmModule implements Module { function stubChannelRegistry(framework: AgentFramework) { const routed: Array<{ text: string; locus: string | null }> = []; const explicit: Record = { - resolveLocus: () => 'dm-antra', - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); + resolveLocus: () => ({ serverId: 'stub', channelId: 'dm-antra' }), + routeSpeech: async (_agent: string, text: string, locus?: { serverId: string; channelId: string } | null) => { + routed.push({ text, locus: locus?.channelId ?? null }); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, diff --git a/test/deferred-window-only.test.ts b/test/deferred-window-only.test.ts index 764fda18b..4b4042acf 100644 --- a/test/deferred-window-only.test.ts +++ b/test/deferred-window-only.test.ts @@ -45,11 +45,15 @@ import type { ToolResult, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; +import { destinationKey, type ChannelRef } from '../src/mcpl/channel-registry.js'; import { heldHandle } from '../src/held-writes.js'; import { AutobiographicalStrategy, MalformedContentError } from '@animalabs/context-manager'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import type { ContentBlock } from '@animalabs/membrane'; +/** A channel on the server these tests' channel messages come from, as each channel message's metadata names it. */ +const srv = (channelId: string): ChannelRef => ({ serverId: 'srv', channelId }); + interface Interjection { participant: string; content: ContentBlock[]; @@ -1491,7 +1495,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { // A logical turn still open between its streams, engaged with chan-beta: // no turn is alive, so no tool boundary weighs what is stored now, and // it's noted as one of the turn's arrivals. - const open = { engaged: new Set(['chan-beta']), arrivals: [] as Array<{ messageId: string; channelId: string }> }; + const open = { engaged: new Set([destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef }> }; internals.openLogical.set('assistant', open); const errors: string[] = []; const error = console.error; @@ -1499,7 +1503,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { let written: unknown[]; try { written = internals.readdDrained( - [{ id: 'w1', seq: 1, participant: 'Antra', content: text('from beta'), metadata: { channelId: 'chan-beta' } }], + [{ id: 'w1', seq: 1, participant: 'Antra', content: text('from beta'), metadata: { channelId: 'chan-beta', serverId: 'srv' } }], 'test flush', ); } finally { console.error = error; } @@ -1510,7 +1514,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { const copies = framework.getAgent('assistant')!.getContextManager().getAllMessages() .filter((m) => (m.metadata as { deferredWriteId?: string } | undefined)?.deferredWriteId === 'w1'); assert.equal(copies.length, 1, 'stored once'); - assert.deepEqual(open.arrivals, [{ messageId: copies[0]!.id, channelId: 'chan-beta' }], 'and noted as one of the open turn\'s arrivals'); + assert.deepEqual(open.arrivals, [{ messageId: copies[0]!.id, channel: srv('chan-beta') }], 'and noted as one of the open turn\'s arrivals'); await framework.stop(); }); @@ -1521,29 +1525,29 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { const internals = framework as unknown as { openLogical: Map; readdDrained(entries: Array<{ id: string; seq: number; participant: string; content: ContentBlock[]; metadata?: Record }>, where: string): unknown[]; - remainingArrival(agent: unknown, routing: unknown): string | undefined; + remainingArrival(agent: unknown, routing: unknown): ChannelRef | undefined; }; // An open turn engaged with both channels, between its streams: the // earlier write throws after it's stored, and the later one lands. - const open = { engaged: new Set(['chan-alpha', 'chan-beta']), arrivals: [] as Array<{ messageId: string; channelId: string }> }; + const open = { engaged: new Set([destinationKey(srv('chan-alpha')), destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef }> }; internals.openLogical.set('assistant', open); const errors: string[] = []; const error = console.error; console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; try { internals.readdDrained([ - { id: 'w1', seq: 1, participant: 'Antra', content: text('from alpha'), metadata: { channelId: 'chan-alpha' } }, - { id: 'w2', seq: 2, participant: 'Antra', content: text('from beta'), metadata: { channelId: 'chan-beta' } }, + { id: 'w1', seq: 1, participant: 'Antra', content: text('from alpha'), metadata: { channelId: 'chan-alpha', serverId: 'srv' } }, + { id: 'w2', seq: 2, participant: 'Antra', content: text('from beta'), metadata: { channelId: 'chan-beta', serverId: 'srv' } }, ], 'test flush'); } finally { console.error = error; } assert.equal(errors.filter((e) => e.includes('threw after it was stored')).length, 1, errors.join('\n')); const idOf = (writeId: string) => agent.getContextManager().getAllMessages() .find((m) => (m.metadata as { deferredWriteId?: string } | undefined)?.deferredWriteId === writeId)!.id; assert.deepEqual(open.arrivals, [ - { messageId: idOf('w1'), channelId: 'chan-alpha' }, - { messageId: idOf('w2'), channelId: 'chan-beta' }, + { messageId: idOf('w1'), channel: srv('chan-alpha') }, + { messageId: idOf('w2'), channel: srv('chan-beta') }, ]); - assert.equal(internals.remainingArrival(agent, open), 'chan-beta', "the turn's newest arrival is the later write's"); + assert.deepEqual(internals.remainingArrival(agent, open), srv('chan-beta'), "the turn's newest arrival is the later write's"); await framework.stop(); }); diff --git a/test/end-turn-usage.test.ts b/test/end-turn-usage.test.ts index af9c157c8..6529d3c82 100644 --- a/test/end-turn-usage.test.ts +++ b/test/end-turn-usage.test.ts @@ -656,12 +656,12 @@ describe('a tool-ended turn ends what a successful turn ends', () => { // cooldown. Its turns aren't an external message's default target, so // the turn is started directly. const internal = r.framework as unknown as { - conversationAgentHomes: Map; + conversationAgentHomes: Map; startAgentStream(agent: unknown, trigger: unknown): Promise; }; r.framework.start(); try { - internal.conversationAgentHomes.set('resident', 'world:zz'); + internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); r.framework.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'first' }]); await internal.startAgentStream(r.framework.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); diff --git a/test/ephemeral-no-global-fallback.test.ts b/test/ephemeral-no-global-fallback.test.ts index ce70c2b1d..02395db45 100644 --- a/test/ephemeral-no-global-fallback.test.ts +++ b/test/ephemeral-no-global-fallback.test.ts @@ -18,19 +18,22 @@ import { fixture, eventually, settles, TS } from './helpers/coalescing-fixture.j import { createMockResponse } from './helpers/mock-membrane.js'; type Fixture = Awaited>; +type Ref = { serverId: string; channelId: string }; type Registry = { - getDefaultPublishChannel(): string | null; + getDefaultPublishChannel(): Ref | null; getDesiredState(serverId: string, channelId: string): string | undefined; - resolveLocus(conversationId: string): string | null; - buildChannelContext(agentName?: string): { incoming?: { channelId: string }; defaultOutgoing?: { channelId: string } } | undefined; - routeSpeech(conversationId: string, text: string, locus: string | null): Promise; - deliverSpeech(conversationId: string, text: string, locus: string | null, opts?: unknown): Promise; + resolveLocus(conversationId: string): Ref | null; + buildChannelContext(agentName: string | undefined, serverId: string): { incoming?: { channelId: string }; defaultOutgoing?: { channelId: string } } | undefined; + routeSpeech(conversationId: string, text: string, locus: Ref | null): Promise; + deliverSpeech(conversationId: string, text: string, locus: Ref | null, opts?: unknown): Promise; handleChannelToolCall(toolName: string, input: unknown, origin?: unknown): Promise<{ success: boolean; error?: string }>; }; const registry = (f: Fixture) => (f.framework as unknown as { channelRegistry: Registry }).channelRegistry; const BUSY = 'discord:g1:busy'; const SIDE = 'discord:g1:side'; +/** A channel on the fixture's server. */ +const editor = (channelId: string): Ref => ({ serverId: 'editor', channelId }); const text = (t: string) => createMockResponse([{ type: 'text', text: t }]); const publishedText = (f: Fixture) => JSON.stringify(f.published); @@ -50,7 +53,7 @@ async function busyFallback(f: Fixture): Promise { }], }); await f.framework.runUntilIdle(); // the resident answers with the fixture's scripted 'ok' - assert.equal(registry(f).getDefaultPublishChannel(), BUSY, 'control: busy is the global fallback'); + assert.deepEqual(registry(f).getDefaultPublishChannel(), editor(BUSY), 'control: busy is the global fallback'); } /** @@ -124,7 +127,7 @@ describe('an ephemeral run takes no global fallback (#551)', () => { // Control: a name the framework didn't reserve for a run, with no // turn of its own (a resident's heartbeat, say), still resolves the // fallback. - assert.equal(registry(f).resolveLocus('resident-with-no-turn'), BUSY, 'control: the fallback is kept for others'); + assert.deepEqual(registry(f).resolveLocus('resident-with-no-turn'), editor(BUSY), 'control: the fallback is kept for others'); } finally { t.off(); await f.close(); } }); } @@ -161,12 +164,12 @@ describe('an ephemeral run takes no global fallback (#551)', () => { await busyFallback(f); for (const name of ['sub-context', '']) { await f.framework.createEphemeralAgent({ name, model: 'test', systemPrompt: 'Do the task.', proseRouting: 'locus' }); - const sub = registry(f).buildChannelContext(name); + const sub = registry(f).buildChannelContext(name, 'editor'); assert.ok(sub, `${JSON.stringify(name)} has a channel context: busy is open`); assert.equal(sub.defaultOutgoing, undefined, JSON.stringify(name)); assert.equal(sub.incoming, undefined, JSON.stringify(name)); } - const resident = registry(f).buildChannelContext('agent'); + const resident = registry(f).buildChannelContext('agent', 'editor'); assert.equal(resident?.defaultOutgoing?.channelId, BUSY, 'control'); assert.equal(resident?.incoming?.channelId, BUSY, 'control'); } finally { await f.close(); } @@ -179,10 +182,10 @@ describe('an ephemeral run takes no global fallback (#551)', () => { await f.framework.createEphemeralAgent({ name: '', model: 'test', systemPrompt: 'Do the task.', proseRouting: 'locus' }); // The run's turn opened side: its routing state names it, as // channel_open's result leaves it. - (f.framework as unknown as { turnRouting: Map }).turnRouting.set('', { locus: SIDE }); + (f.framework as unknown as { turnRouting: Map }).turnRouting.set('', { locus: editor(SIDE) }); - assert.equal(registry(f).resolveLocus(''), SIDE, 'control: its speech goes to side'); - assert.equal(registry(f).buildChannelContext('')?.defaultOutgoing?.channelId, SIDE, 'and its channel context says so'); + assert.deepEqual(registry(f).resolveLocus(''), editor(SIDE), 'control: its speech goes to side'); + assert.equal(registry(f).buildChannelContext('', 'editor')?.defaultOutgoing?.channelId, SIDE, 'and its channel context says so'); } finally { await f.close(); } }); @@ -249,11 +252,11 @@ describe('an ephemeral run takes no global fallback (#551)', () => { }], }); await f.framework.runUntilIdle(); - const fork = (f.framework as unknown as { getConversationRouter(): { getBinding(id: string): { agentName: string } | undefined } }) - .getConversationRouter().getBinding(BUSY)?.agentName; + const fork = (f.framework as unknown as { getConversationRouter(): { getBinding(channel: Ref): { agentName: string } | undefined } }) + .getConversationRouter().getBinding(editor(BUSY))?.agentName; assert.ok(fork, 'a conversation fork was spawned for busy'); - assert.equal(registry(f).buildChannelContext(fork)?.incoming?.channelId, BUSY, 'its channel context still names the latest inbound message'); + assert.equal(registry(f).buildChannelContext(fork, 'editor')?.incoming?.channelId, BUSY, 'its channel context still names the latest inbound message'); } finally { await f.close(); } }); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 87640a628..f8511e566 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -30,16 +30,18 @@ interface TraceEntry { function makeGate(configPath: string, opts?: { initialConfig?: GateConfig; resolveRouteChannel?: (info: GateEventInfo) => string | undefined; + channelShown?: (channel: { serverId: string; channelId: string }) => string; privilegedUsersPath?: string; }) { const traces: TraceEntry[] = []; const messages: Array<{ participant: string; content: unknown; metadata?: unknown }> = []; - const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string }> = []; + const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string; routeServerId?: string }> = []; const gate = new EventGate({ configPath, initialConfig: opts?.initialConfig, ...(opts?.resolveRouteChannel ? { resolveRouteChannel: opts.resolveRouteChannel } : {}), + ...(opts?.channelShown ? { channelShown: opts.channelShown } : {}), ...(opts?.privilegedUsersPath ? { privilegedUsersPath: opts.privilegedUsersPath } : {}), emitTrace: (e) => traces.push(e as TraceEntry), addMessage: (p, c, m) => { messages.push({ participant: p, content: c, metadata: m }); return ''; }, @@ -411,6 +413,58 @@ describe('debounce', () => { assert.doesNotMatch(text, /##/); }); + it("a batched wake names two servers' channels with one id apart, each with its own count", () => { + // Channel ids are unique only within one server's connection. + const path = writeConfig('debounce-shared-id.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path, { channelShown: (c) => `${c.serverId}:${c.channelId}` }); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'a', channelId: 'general', content: 'one', + metadata: { channelName: 'general' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'b', channelId: 'general', content: 'two', + metadata: { channelName: 'general' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'b', channelId: 'general', content: 'three', + metadata: { channelName: 'general' } })); + const batch = gate as unknown as { + debounceTimers: Map }>; + fireDebounce(policyName: string): void; + }; + clearTimeout(batch.debounceTimers.get('chat')!.timer); + batch.fireDebounce('chat'); + + const text = (messages[0]!.content as Array<{ text: string }>)[0]!.text; + assert.match(text, /- 1 message in #general \(a:general\)/); + assert.match(text, /- 2 messages in #general \(b:general\)/); + }); + + it("a batched wake shows a push event's adapter id as it is, and only a registered channel as the registry names it", () => { + const path = writeConfig('debounce-raw-id.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path, { channelShown: (c) => `shown ${c.serverId} ${c.channelId}` }); + // A push event carries the adapter's raw id, which no route maps to a + // registered channel here; a channel message names its registered one. + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', channelId: '1548', content: 'dm' })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: 'hi' })); + const batch = gate as unknown as { + debounceTimers: Map }>; + fireDebounce(policyName: string): void; + }; + clearTimeout(batch.debounceTimers.get('chat')!.timer); + batch.fireDebounce('chat'); + + const text = (messages[0]!.content as Array<{ text: string }>)[0]!.text; + assert.match(text, /- 1 message in 1548\b/); + assert.match(text, /- 1 message in shown discord discord:g:room\b/); + assert.doesNotMatch(text, /shown discord 1548/, "an adapter's raw id isn't named as a registered channel"); + }); + it('a quoted event\'s content is cut where no surrogate pair is halved', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const path = writeConfig('debounce-pair.json', { @@ -526,6 +580,7 @@ describe('debounce', () => { await new Promise(r => setTimeout(r, 150)); assert.strictEqual(inferenceRequests.length, 1); assert.strictEqual(inferenceRequests[0].routeChannelId, 'discord:dm:1548', 'the addressed event names the route'); + assert.strictEqual(inferenceRequests[0].routeServerId, 'discord', "and the route's server, since ids are unique only per server"); assert.strictEqual(inferenceRequests[0].counterparty, 'discord:user:42', 'route and author come from the same event'); assert.strictEqual(inferenceRequests[0].channelId, undefined, 'telemetry channel unchanged for push events'); // The batched-wake line names the registered id, not the raw snowflake. @@ -539,6 +594,7 @@ describe('debounce', () => { assert.strictEqual(inferenceRequests.length, 2); assert.strictEqual(inferenceRequests[1].channelId, 'discord:g:room'); assert.strictEqual(inferenceRequests[1].routeChannelId, undefined, 'ambient chatter never sets a route'); + assert.strictEqual(inferenceRequests[1].routeServerId, undefined, 'nor its server'); }); it('the default resolver routes an addressed channel-incoming event; a push event needs the host resolver', async () => { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index fd7db0c35..b97e4e704 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -25,6 +25,7 @@ import type { InferenceRequest, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; +import { destinationKey, type ChannelRef } from '../src/mcpl/channel-registry.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { draftState, type ProseDraftStore } from '../src/prose-drafts.js'; import { MembraneError } from '@animalabs/membrane'; @@ -76,6 +77,11 @@ class ToolboxModule implements Module { const streamedAt = (to: { serverId: string; channelId: string }) => (to.serverId === 'stub' ? to.channelId : `${to.channelId} (server ${to.serverId})`); +/** The server a stub channel is on: the body's for its own channels, the stub's for the rest. */ +const serverOf = (channelId: string) => (channelId.startsWith('body:') ? 'body' : 'stub'); +/** A stub channel, with its server. */ +const stub = (channelId: string): ChannelRef => ({ serverId: serverOf(channelId), channelId }); + /** Registry stub: two known channels (#alpha guild, laria's DM) + capture. */ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'false' | 'unknown' | 'throw'> = []) { const routed: Array<{ text: string; locus: string | null }> = []; @@ -88,11 +94,13 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals '@laria': { channelId: 'discord:dm:99', label: 'DM: laria' }, 'discord:dm:99': { channelId: 'discord:dm:99', label: 'DM: laria' }, }; - const deliverSpeech = async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); + // The framework hands delivery the channel with its server; the test + // records the channel id, and the stub's server is the only one. + const deliverSpeech = async (_agent: string, text: string, locus?: { serverId?: string; channelId: string } | null) => { + routed.push({ text, locus: locus?.channelId ?? null }); const behavior = plan.shift() ?? 'delivered'; if (behavior === 'throw') throw new Error('synthetic publication failure'); - const destination = { serverId: 'stub', channelId: locus ?? '' }; + const destination = { serverId: locus?.serverId ?? 'stub', channelId: locus?.channelId ?? '' }; if (behavior === 'unknown') { return { status: 'unknown' as const, destination, reason: 'stub delivery uncertain', at: Date.now() }; } @@ -107,21 +115,24 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals spec === '#ears' ? opts?.forSpeech ? { error: 'body:ears is inbound-only: server "body" declared this channel\'s direction "inbound", so the host publishes nothing into it; to speak there, use that server\'s own tools', refusedChannelId: 'body:ears' } - : { channelId: 'body:ears', label: 'Body · ears' } - : known[spec] ?? { error: 'no channel matches "' + spec + '"' }, + : { channelId: 'body:ears', serverId: 'body', label: 'Body · ears' } + : known[spec] ? { serverId: 'stub', ...known[spec] } : { error: 'no channel matches "' + spec + '"' }, deliverSpeech, - routeSpeech: async (agent: string, text: string, locus?: string | null) => { + routeSpeech: async (agent: string, text: string, locus?: { serverId?: string; channelId: string } | null) => { const outcome = await deliverSpeech(agent, text, locus); return outcome.status === 'delivered' ? { delivered: true, serverId: outcome.destination.serverId, channelId: outcome.destination.channelId } : null; }, resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), - resolveLocus: () => 'world:commons', + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:commons' }), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // A channel id names one channel here, on the server it belongs to. + channelsNamed: (name: string) => [{ serverId: serverOf(name), channelId: name }], + nameShown: (channel: { channelId: string }) => channel.channelId, }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { get: (target, prop: string) => (prop in target ? target[prop] : () => undefined), @@ -351,7 +362,7 @@ describe('explicit prose routing', () => { const agent = framework.getAgent('assistant')!; const gateway = framework as unknown as { deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; - deliverHybridProse(a: unknown, text: string, locus: null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; + deliverHybridProse(a: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; routingOf(name: string): unknown; }; if (mode === 'explicit') await gateway.deliverProse(agent, '>>#alpha uncertain words', { notice: 'later' }); @@ -531,7 +542,7 @@ describe('explicit prose routing', () => { const internals = framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; - deliverHybridProse(a: unknown, text: string, locus: string | null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; + deliverHybridProse(a: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; routingOf(name: string): unknown; }; const deliver = internals.channelRegistry.deliverSpeech; @@ -618,7 +629,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const store = draftsOf(framework); const gateway = framework as unknown as { - deliverHybridProse(a: unknown, text: string, locus: string | null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; + deliverHybridProse(a: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: unknown; notice: 'later' }): Promise; routingOf(name: string): unknown; }; await gateway.deliverHybridProse(framework.getAgent('assistant')!, '>>>#alpha {{unsent}} and a note', null, true, { routing: gateway.routingOf('assistant'), notice: 'later' }); @@ -718,14 +729,14 @@ describe('explicit prose routing', () => { type RoutingState = { - pin?: string; hybridHold?: 'private' | 'failed'; - continuation: boolean; deliveries: string[]; suppressed: number; drafts: Array<{ id: string; text: string }>; keptPrivate: number; - triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; + pin?: ChannelRef; hybridHold?: 'private' | 'failed'; + continuation: boolean; deliveries: ChannelRef[]; suppressed: number; drafts: Array<{ id: string; text: string }>; keptPrivate: number; + triggerChannel?: ChannelRef; locus?: ChannelRef; engaged: Set; wasIn: ChannelRef[]; arrivals: Array<{ messageId: string; channel: ChannelRef }>; }; /** A turn's routing state, as the framework makes a fresh one. */ const routingState = (fields: Partial = {}): RoutingState => ({ continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, - engaged: new Set(), arrivals: [], ...fields, + engaged: new Set(), wasIn: [], arrivals: [], ...fields, }); /** @@ -881,7 +892,7 @@ describe('explicit prose routing', () => { inspect: (fixture: { framework: AgentFramework; routed: Array<{ text: string; locus: string | null }>; gateway: { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; + deliverHybridProse(agent: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; earlier: RoutingState; next: RoutingState; agent: unknown; @@ -892,7 +903,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const gateway = framework as unknown as { turnRouting: Map; - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; + deliverHybridProse(agent: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; const agent = framework.getAgent('assistant')!; @@ -907,9 +918,9 @@ describe('explicit prose routing', () => { resolveEntered(); await held; }); - const first = gateway.deliverHybridProse(agent, '>>>#alpha first', 'world:commons', true, { routing: earlier, notice: 'later' }); + const first = gateway.deliverHybridProse(agent, '>>>#alpha first', stub('world:commons'), true, { routing: earlier, notice: 'later' }); const pending = lateText === null ? first - : first.then(() => gateway.deliverHybridProse(agent, lateText, 'world:commons', true, { routing: earlier, notice: 'later' })); + : first.then(() => gateway.deliverHybridProse(agent, lateText, stub('world:commons'), true, { routing: earlier, notice: 'later' })); void pending.catch(() => {}); try { await within(entered, 'the earlier callback to enter routing'); @@ -1076,7 +1087,7 @@ describe('explicit prose routing', () => { // its queued delivery runs; the turn goes on in a re-inference, which // starts a new stream with a fresh routing state while the earlier // stream's logical turn is still open. - const earlier: RoutingState = routingState({ continuation: when === 'already set', locus: 'world:commons' }); + const earlier: RoutingState = routingState({ continuation: when === 'already set', locus: stub('world:commons') }); internals.turnRouting.set('assistant', earlier); internals.openLogical.set('assistant', earlier); if (when === 'set late') { @@ -1127,10 +1138,10 @@ describe('explicit prose routing', () => { // still open; its delivery lands only after the re-inference has // begun, into the logical turn the two share. The turn froze the // registry's locus when it opened. - const earlier: RoutingState = routingState({ locus: 'world:commons' }); + const earlier: RoutingState = routingState({ locus: stub('world:commons') }); internals.turnRouting.set('assistant', earlier); internals.openLogical.set('assistant', earlier); - internals.proseSpeechChains.set('assistant', held.then(() => { earlier.deliveries.push('chan-alpha'); })); + internals.proseSpeechChains.set('assistant', held.then(() => { earlier.deliveries.push(stub('chan-alpha')); })); internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); const running = framework.runUntilIdle(); for (const end = Date.now() + 5000; turns < 1 && Date.now() < end;) { @@ -1347,7 +1358,7 @@ describe('explicit prose routing', () => { }; const origin = internals.routingOf('assistant'); origin.continuation = true; - origin.deliveries.push('chan-alpha'); + origin.deliveries.push(stub('chan-alpha')); // The turn that ran the tools is open, as its fresh start left it. internals.openLogical.set('assistant', origin); const response = createMockResponse([ @@ -1410,8 +1421,9 @@ describe('explicit prose routing', () => { try { // Everything the open turn has decided about where its speech goes. const decided = (): RoutingState => routingState({ - continuation: true, pin: 'chan-alpha', hybridHold: 'failed', triggerChannel: 'chan-alpha', - locus: 'world:commons', engaged: new Set(['discord:g:cafe']), arrivals: [{ messageId: 'cafe-message', channelId: 'discord:g:cafe' }], + continuation: true, pin: stub('chan-alpha'), hybridHold: 'failed', triggerChannel: stub('chan-alpha'), + locus: stub('world:commons'), engaged: new Set([destinationKey(stub('discord:g:cafe'))]), wasIn: [], + arrivals: [{ messageId: 'cafe-message', channel: stub('discord:g:cafe') }], }); const open = decided(); internals.openLogical.set('assistant', open); @@ -1448,7 +1460,7 @@ describe('explicit prose routing', () => { stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const typing: string[] = []; - registry.startTyping = (channelId: string) => { typing.push(channelId); }; + registry.startTyping = (channel: { channelId: string }) => { typing.push(channel.channelId); }; const started: Array = []; framework.onTrace((event) => { if (event.type === 'inference:started') started.push((event as { channelId?: string }).channelId); @@ -1459,7 +1471,7 @@ describe('explicit prose routing', () => { try { // A silent turn opened a channel, which moved its routing state's // locus, and its stream then crossed the budget. - internals.turnRouting.set('assistant', routingState({ locus: 'discord:g:cafe' })); + internals.turnRouting.set('assistant', routingState({ locus: stub('discord:g:cafe') })); internals.pendingRequests.push({ agentName: 'assistant', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), suppressProse: true } as InferenceRequest); await within(framework.runUntilIdle(), 'the silent restart', 3000); assert.deepEqual(started, [undefined], 'the restart reports no locus'); @@ -1562,7 +1574,7 @@ describe('explicit prose routing', () => { await within(pending, 'the late malformed envelope'); assert.equal(earlier.hybridHold, 'failed', 'the late hold belongs to the supplied earlier turn'); assert.equal(next.hybridHold, undefined, 'the newer object stays unheld'); - await gateway.deliverHybridProse(agent, 'next', 'world:commons', true, { routing: next, notice: 'later' }); + await gateway.deliverHybridProse(agent, 'next', stub('world:commons'), true, { routing: next, notice: 'later' }); assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [ { text: 'first', locus: 'chan-alpha' }, { text: 'next', locus: 'world:commons' }, @@ -1572,7 +1584,7 @@ describe('explicit prose routing', () => { it("hybrid callbacks and receipts keep each turn's counts while a post is held", async () => { await withHeldHybridCallback(null, async ({ framework, earlier, next, gateway, agent, pending, release }) => { - await gateway.deliverHybridProse(agent, 'quiet without a destination', 'world:commons', false, { routing: next, notice: 'later' }); + await gateway.deliverHybridProse(agent, 'quiet without a destination', stub('world:commons'), false, { routing: next, notice: 'later' }); release(); await within(pending, 'the earlier delivery confirmation'); gateway.appendProseDeliveryReceipt(agent, earlier); @@ -1657,8 +1669,8 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const typing: string[] = []; - registry.startTyping = (channelId: string) => { typing.push(`start ${channelId}`); }; - registry.stopTyping = (channelId?: string) => { typing.push(`stop ${channelId ?? '(all)'}`); }; + registry.startTyping = (channel: { channelId: string }) => { typing.push(`start ${channel.channelId}`); }; + registry.stopTyping = (channel?: { channelId: string }) => { typing.push(`stop ${channel?.channelId ?? '(all)'}`); }; const traced = new Set(); framework.onTrace((event) => { const e = event as { type: string; blockType?: string; channelId?: string }; @@ -1741,7 +1753,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, + deliverHybridProse(agent: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: RoutingState; notice: 'later'; onPublished?: (to: { channelId: string }, text: string) => void }): Promise; turnRouting: Map; }; @@ -1754,7 +1766,7 @@ describe('explicit prose routing', () => { const live: RoutingState = routingState(); gateway.turnRouting.set('assistant', live); const streamed: string[] = []; - await gateway.deliverHybridProse(agent, text, 'chan-locus', true, + await gateway.deliverHybridProse(agent, text, stub('chan-locus'), true, { routing: queued, notice: 'later', onPublished: (_channel, body) => streamed.push(body) }); assert.equal(queued.hybridHold, 'failed', 'the supplied turn receives the hold'); assert.equal(live.hybridHold, undefined, 'the live turn stays unheld'); @@ -1770,7 +1782,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, + deliverHybridProse(agent: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: RoutingState; notice: 'later'; onPublished?: (to: { channelId: string }, text: string) => void }): Promise; turnRouting: Map; }; @@ -1787,10 +1799,10 @@ describe('explicit prose routing', () => { notice: 'later' as const, onPublished: ({ channelId }: { channelId: string }, text: string) => { streamed.push({ channelId, text }); }, }; - await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true, round); - await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true, round); - await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true, round); - assert.deepEqual(queued.deliveries, ['chan-alpha'], 'the supplied turn records its delivery'); + await gateway.deliverHybridProse(agent, '>>>#alpha first', stub('chan-locus'), true, round); + await gateway.deliverHybridProse(agent, prefix, stub('chan-locus'), true, round); + await gateway.deliverHybridProse(agent, 'secret', stub('chan-locus'), true, round); + assert.deepEqual(queued.deliveries, [stub('chan-alpha')], 'the supplied turn records its delivery'); assert.ok(queued.drafts.some((d) => d.text === 'secret'), 'the supplied turn holds the held-back segment as its draft'); assert.deepEqual(live.deliveries, [], 'the live turn receives no delivery'); assert.deepEqual(live.drafts, [], 'the live turn holds no draft'); @@ -1836,7 +1848,7 @@ describe('explicit prose routing', () => { const agent = framework.getAgent('assistant')!; try { // An earlier round of the turn pinned #alpha. - const routing: RoutingState = routingState({ pin: 'chan-alpha' }); + const routing: RoutingState = routingState({ pin: stub('chan-alpha') }); gateway.holdSilencedProse(agent, ['>>>#ears for the room', 'after it'], 'send', false, { routing, notice: 'later' }); assert.equal(routing.pin, undefined, 'the inbound-only channel is never pinned, and the earlier pin is dropped'); assert.equal(routing.hybridHold, 'failed', 'the envelope fails as a refused target does at delivery'); @@ -1850,13 +1862,13 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; + deliverHybridProse(agent: unknown, text: string, locus: ChannelRef | null, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; }; const agent = framework.getAgent('assistant')!; try { const routing: RoutingState = routingState(); - await gateway.deliverHybridProse(agent, '>>>skip_reply an aside', 'chan-locus', true, { routing, notice: 'later' }); - await gateway.deliverHybridProse(agent, 'still private', 'chan-locus', true, { routing, notice: 'later' }); + await gateway.deliverHybridProse(agent, '>>>skip_reply an aside', stub('chan-locus'), true, { routing, notice: 'later' }); + await gateway.deliverHybridProse(agent, 'still private', stub('chan-locus'), true, { routing, notice: 'later' }); assert.deepEqual(routed, [], 'nothing is sent'); assert.equal(routing.hybridHold, 'private'); assert.equal(routing.keptPrivate, 1, 'the unprefixed envelope after it is counted as kept private'); @@ -1959,7 +1971,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const internals = framework as unknown as { channelRegistry: { resolveProseTarget: (spec: string) => unknown }; - admitProse(text: string): Array<{ text: string; resolved?: { channelId?: string; error?: string } }>; + admitProse(text: string, routing: RoutingState): Array<{ text: string; resolved?: { channelId?: string; error?: string } }>; deliverAdmittedProse(agent: unknown, envelopes: unknown[], round: { routing: RoutingState }): Promise; routingOf(name: string): RoutingState; }; @@ -1969,7 +1981,7 @@ describe('explicit prose routing', () => { return resolve(spec); }; try { - const admitted = internals.admitProse('>>#boom one\n>>#alpha two'); + const admitted = internals.admitProse('>>#boom one\n>>#alpha two', internals.routingOf('assistant')); assert.match(admitted[0]!.resolved?.error ?? '', /directory unavailable/); await internals.deliverAdmittedProse(framework.getAgent('assistant')!, admitted, { routing: internals.routingOf('assistant') }); assert.deepEqual(routed.map((r) => r.text), ['two'], 'the other envelope still goes out'); @@ -2081,6 +2093,8 @@ describe('explicit prose routing', () => { 'A destination applies to the rest of that turn, until another is named.', 'A name that matches no channel, or several, bounces and leaves it as it was.', 'A channel its server declared inbound-only is refused, and clears it.', + 'Where two servers register one channel id, the server goes first: >>server:service:guild:id … (a bounce lists each such name).', + "Within a turn, the id of a channel the turn has been in means that channel, on its server, and bounces once that server no longer registers it; where two of the turn's channels have the id, it means neither and bounces.", ]) { assert.ok(help.includes(clause), `the help says: ${clause}`); } @@ -2191,7 +2205,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('explicit'); stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; - registry.inboundOnlyServer = (channelId: string) => (channelId === 'body:ears' ? 'body' : undefined); + registry.inboundOnlyServer = (channel: { channelId: string }) => (channel.channelId === 'body:ears' ? 'body' : undefined); const chunks: Array<{ channelId: string; delta: string }> = []; const completes: Array<{ channelId: string; text: string }> = []; registry.sendOutgoingChunk = (to: { serverId: string; channelId: string }, _agent: string, _inference: string, _index: number, delta: string) => { @@ -2225,14 +2239,15 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const declared = new Set(); - registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); + registry.inboundOnlyServer = (channel: { channelId: string }) => (declared.has(channel.channelId) ? 'body' : undefined); // The turn's routing, as its first delivery sees it. - let turn: { pin?: string } | undefined; - registry.deliverSpeech = async (_agent: string, text: string, locus?: string | null) => { - turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + let turn: { pin?: ChannelRef } | undefined; + registry.deliverSpeech = async (_agent: string, text: string, to?: ChannelRef | null) => { + turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + const locus = to?.channelId; routed.push({ text, locus: locus ?? null }); if (inboundOnly || refusal === 'delivered, then declared') declared.add(locus ?? ''); - const destination = { serverId: 'stub', channelId: locus ?? '' }; + const destination = { serverId: to?.serverId ?? 'stub', channelId: locus ?? '' }; if (refusal === 'delivered, then declared') return { status: 'delivered', destination, at: Date.now() }; // A publish that may have been posted, as one that timed out may. if (refusal.endsWith('unconfirmed')) return { status: 'unknown', destination, reason: 'channels/publish timed out (delivery uncertain)', at: Date.now() }; @@ -2251,7 +2266,7 @@ describe('explicit prose routing', () => { // Each failed delivery bounces its own words; none bounces for want of a destination. assert.equal(bounces.filter((t) => t.includes('delivery to chan-alpha failed')).length, refusal === 'another failure' ? 2 : 0); assert.equal(bounces.some((t) => t.includes('this turn has no destination now')), false); - assert.equal(turn?.pin, 'chan-alpha'); + assert.deepEqual(turn?.pin, stub('chan-alpha')); } await framework.stop(); }); @@ -2272,15 +2287,16 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const declared = new Set(); - registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); - let turn: { pin?: string } | undefined; - let atFailure: { pin?: string } | undefined; - registry.deliverSpeech = async (_agent: string, text: string, locus?: string | null) => { - turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + registry.inboundOnlyServer = (channel: { channelId: string }) => (declared.has(channel.channelId) ? 'body' : undefined); + let turn: { pin?: ChannelRef } | undefined; + let atFailure: { pin?: ChannelRef } | undefined; + registry.deliverSpeech = async (_agent: string, text: string, to?: ChannelRef | null) => { + turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + const locus = to?.channelId; routed.push({ text, locus: locus ?? null }); - const destination = { serverId: 'stub', channelId: locus ?? '' }; + const destination = { serverId: to?.serverId ?? 'stub', channelId: locus ?? '' }; if (locus === 'chan-alpha') { - turn!.pin = 'discord:g:cafe'; + turn!.pin = stub('discord:g:cafe'); atFailure = { pin: turn!.pin }; declared.add('chan-alpha'); return { status: 'failed', destination, reason: 'inbound-only: server "body" declared it', at: Date.now() }; @@ -2290,8 +2306,8 @@ describe('explicit prose routing', () => { feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(atFailure, { pin: 'discord:g:cafe' }, 'the state the clear met'); - assert.equal(turn?.pin, 'discord:g:cafe', 'the moved pin stays'); + assert.deepEqual(atFailure, { pin: stub('discord:g:cafe') }, 'the state the clear met'); + assert.deepEqual(turn?.pin, stub('discord:g:cafe'), 'the moved pin stays'); assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }, { text: 'and more', locus: 'discord:g:cafe' }]); await framework.stop(); }); @@ -2316,18 +2332,19 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const declared = new Set(); - registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); - const turnOf = () => (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); - let turn: { pin?: string } | undefined; + registry.inboundOnlyServer = (channel: { channelId: string }) => (declared.has(channel.channelId) ? 'body' : undefined); + const turnOf = () => (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + let turn: { pin?: ChannelRef } | undefined; // MockMembrane gives a turn's rounds to its one stream, so a round's // tool calls, not the membrane's calls, show how far the turn has got. let toolRounds = 0; framework.onTrace((e) => { if ((e as { type: string }).type === 'inference:tool_calls_yielded') toolRounds++; }); let roundsAtFailure = 0; - registry.deliverSpeech = async (_agent: string, text: string, locus?: string | null) => { + registry.deliverSpeech = async (_agent: string, text: string, to?: ChannelRef | null) => { turn ??= turnOf(); + const locus = to?.channelId; routed.push({ text, locus: locus ?? null }); - const destination = { serverId: 'stub', channelId: locus ?? '' }; + const destination = { serverId: to?.serverId ?? 'stub', channelId: locus ?? '' }; if (locus === 'chan-alpha') { // Hold #alpha's delivery until round 2 is admitted, as its tool calls // show, then refuse it: its server has declared it inbound-only by @@ -2349,7 +2366,7 @@ describe('explicit prose routing', () => { { text: 'second', locus: 'discord:g:cafe' }, { text: 'and more', locus: 'discord:g:cafe' }, ]); - assert.equal(turn?.pin, 'discord:g:cafe'); + assert.deepEqual(turn?.pin, stub('discord:g:cafe')); await framework.stop(); }); @@ -2678,9 +2695,9 @@ describe('explicit prose routing', () => { } for (const [label, mode, left, expected] of [ - ['hybrid: the hold keeps later unprefixed prose private', 'hybrid', { hybridHold: 'private', locus: 'world:commons' }, []], - ['hybrid: the sticky destination', 'hybrid', { pin: 'chan-alpha', locus: 'world:commons' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], - ['explicit: the sticky destination, and the stream streams where delivery publishes', 'explicit', { pin: 'chan-alpha' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], + ['hybrid: the hold keeps later unprefixed prose private', 'hybrid', { hybridHold: 'private', locus: stub('world:commons') }, []], + ['hybrid: the sticky destination', 'hybrid', { pin: stub('chan-alpha'), locus: stub('world:commons') }, [{ text: 'carrying on', locus: 'chan-alpha' }]], + ['explicit: the sticky destination, and the stream streams where delivery publishes', 'explicit', { pin: stub('chan-alpha') }, [{ text: 'carrying on', locus: 'chan-alpha' }]], ] as const) { it(`a re-inference on unstreamed tool results keeps the turn's routing: ${label}`, async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }] as ContentBlock[])); @@ -2729,13 +2746,13 @@ describe('explicit prose routing', () => { const cm = framework.getAgent('assistant')!.getContextManager(); const getMessage = cm.getMessage.bind(cm); try { - const earlier = routingState({ pin: 'chan-alpha' }); + const earlier = routingState({ pin: stub('chan-alpha') }); internals.turnRouting.set('assistant', earlier); internals.openLogical.set('assistant', earlier); // Someone addresses the agent from another channel while the turn waits. internals.addMessage('Antra', [{ type: 'text', text: 'quick question for you' }] as ContentBlock[], { channelId: 'discord:g:cafe', tags: ['chat:addressed'] }); - assert.deepEqual(earlier.arrivals.map((a) => a.channelId), ['discord:g:cafe'], 'it is the turn\'s arrival'); + assert.deepEqual(earlier.arrivals.map((a) => a.channel), [stub('discord:g:cafe')], 'it is the turn\'s arrival, on its server'); if (unreadable) { const arrival = earlier.arrivals[0]!.messageId; cm.getMessage = (id) => { @@ -2823,7 +2840,7 @@ describe('explicit prose routing', () => { assert.deepEqual(routed.map((r) => r.text.trim()), ['zero'], "the retry's text waits behind the held post"); const routing = internals.turnRouting.get('assistant')!; if (mode === 'explicit') { - assert.equal(routing.pin, 'discord:g:cafe', "round two's delivery hasn't run yet"); + assert.deepEqual(routing.pin, stub('discord:g:cafe'), "round two's delivery hasn't run yet"); } else { assert.equal(routing.hybridHold, undefined, "round two's delivery hasn't run yet"); } diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 2bcc6da53..2c84f2dc0 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -19,6 +19,7 @@ import { // The package's public digest: consumers import it from the root. sourceBodyDigest, PassthroughStrategy, + type ChannelRef, type InboundSource, type Module, type ModuleContext, @@ -205,7 +206,7 @@ describe('inbound source envelope', () => { // appended after ingestion stamped the delivered digest, at storage // time, when the channel has closed in between. const registry = (framework as unknown as { - channelRegistry: { handleChannelToolCall(name: string, input: unknown, caller: unknown): Promise; isChannelOpen(id: string): boolean }; + channelRegistry: { handleChannelToolCall(name: string, input: unknown, caller: unknown): Promise; isChannelOpen(channel: ChannelRef): boolean }; }).channelRegistry; const fw = framework as unknown as { pushEvent(e: { type: string }): void }; const held: Array<{ type: string }> = []; @@ -214,7 +215,7 @@ describe('inbound source envelope', () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-closed', mode: 'addressed', text: 'hello while open' }); await waitFor(() => held.length > 0, 'incoming accepted and held'); await registry.handleChannelToolCall('channel_close', { channelId: ROOM }, { kind: 'agent', agentName: 'scout' }); - assert.equal(registry.isChannelOpen(ROOM), false); + assert.equal(registry.isChannelOpen({ serverId: 'discord', channelId: ROOM }), false); fw.pushEvent = push; for (const e of held) push(e); await waitFor(() => !!storedWith((m) => m.messageId === 'm-closed'), 'message stored'); @@ -605,7 +606,8 @@ describe('inbound source envelope under RFC-006 coalescing', () => { const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as never }); t.after(f.close); await f.register(); const forged = { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }; - const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined; unbind(id: string): void } }).conversationRouter; + const router = (f.framework as unknown as { conversationRouter: { getBinding(channel: ChannelRef): { agentName: string } | undefined; unbind(channel: ChannelRef): void } }).conversationRouter; + const chat: ChannelRef = { serverId: 'editor', channelId: 'chat' }; const check = (text: string, fork: string) => { const copy = f.framework.getAgent(fork)!.getContextManager().getAllMessages().find((m) => JSON.stringify(m.content).includes(text)); assert.ok(copy, `${text} stored in ${fork}`); @@ -615,13 +617,13 @@ describe('inbound source envelope under RFC-006 coalescing', () => { }; // Routed into a fresh fork by the conversation router. await f.send('channels/incoming', { messages: [{ ...f.channel('c1', 'routed_original', { initial: true }), metadata: forged }] }); - const fork = router.getBinding('chat')!.agentName; + const fork = router.getBinding(chat)!.agentName; check('routed_original', fork); // A correction follows the prior delivery into that fork, though the // channel is no longer bound to it. - router.unbind('chat'); + router.unbind(chat); await f.send('channels/incoming', { messages: [{ ...f.channel('c2', 'fork_correction'), metadata: forged }] }); - assert.equal(router.getBinding('chat'), undefined, 'no fresh fork was spawned for the correction'); + assert.equal(router.getBinding(chat), undefined, 'no fresh fork was spawned for the correction'); check('fork_correction', fork); }); diff --git a/test/machine-close-provenance.test.ts b/test/machine-close-provenance.test.ts index 243c500d3..f0fcbf193 100644 --- a/test/machine-close-provenance.test.ts +++ b/test/machine-close-provenance.test.ts @@ -1,7 +1,7 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -46,7 +46,7 @@ function makeRegistry() { internals.desiredStates.get(internals.lifecycleKey('discord', channelId)); // Register the channel the way a live server would surface it. - (registry as unknown as { channels: Map }).channels.set('discord:c1', { + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'discord', channelId: 'c1' }), { serverId: 'discord', descriptor: { id: 'c1', type: 'discord', label: '#commons' }, open: false, }); registry.handleIncoming('discord', { diff --git a/test/maintenance-timer.test.ts b/test/maintenance-timer.test.ts index eab606a85..5abca152f 100644 --- a/test/maintenance-timer.test.ts +++ b/test/maintenance-timer.test.ts @@ -1416,7 +1416,7 @@ describe('queued context maintenance timer', () => { ...(conversational ? { conversations: { templateAgent: 'agent', strategyFactory: () => tick } } : {}), }); const internals = framework as unknown as { - createConversationAgent(name: string, channelId: string): Promise; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise; reserveStoreForSurgery(verb: string, agentName: string): () => void; recordProviderWait(agentName: string, model: string, error: Error): unknown; }; @@ -1426,7 +1426,7 @@ describe('queued context maintenance timer', () => { const quiet = console.error; console.error = () => {}; try { - if (conversational) await internals.createConversationAgent(name, 'chan-1'); + if (conversational) await internals.createConversationAgent(name, { serverId: 'stub', channelId: 'chan-1' }); if (held) internals.recordProviderWait(name, 'test-model', heldFor(60_000)); framework.start(); await waitFor(() => tick.entered); @@ -1646,7 +1646,7 @@ describe('queued context maintenance timer', () => { ...(conversational ? { conversations: { templateAgent: 'agent', strategyFactory: () => tick } } : {}), }); const internals = framework as unknown as { - createConversationAgent(name: string, channelId: string): Promise; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise; recordProviderWait(agentName: string, model: string, error: Error): unknown; }; const name = conversational ? 'agent-conv-1' : 'agent'; @@ -1657,7 +1657,7 @@ describe('queued context maintenance timer', () => { console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; let stopping: Promise | undefined; try { - if (conversational) await internals.createConversationAgent(name, 'chan-1'); + if (conversational) await internals.createConversationAgent(name, { serverId: 'stub', channelId: 'chan-1' }); if (held) internals.recordProviderWait(name, 'test-model', heldFor(60_000)); framework.start(); await waitFor(() => tick.entered); @@ -1995,7 +1995,7 @@ describe('queued context maintenance timer', () => { }, }); const internals = framework as unknown as { - createConversationAgent(name: string, channelId: string): Promise; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise; disposeConversationAgent(name: string): void; noteInferenceExhausted(agentName: string, reason: string, retryable?: boolean, errorType?: string): void; overBudgetDrains: Set>; @@ -2008,7 +2008,7 @@ describe('queued context maintenance timer', () => { try { // The kick's body runs a microtask later, so each fork is disposed // before the drain's first check. - await internals.createConversationAgent('trunk-chan-g1', 'world:chan'); + await internals.createConversationAgent('trunk-chan-g1', { serverId: 'stub', channelId: 'world:chan' }); internals.noteInferenceExhausted('trunk-chan-g1', 'compile refused', false, 'over_budget'); assert.equal(internals.overBudgetDrains.size, 1, 'a kick was started'); internals.disposeConversationAgent('trunk-chan-g1'); @@ -2022,7 +2022,7 @@ describe('queued context maintenance timer', () => { ); // A surgery for another agent holding the store as well: the log names // the disposal, the agent's own reason. - await internals.createConversationAgent('trunk-chan-g2', 'world:chan'); + await internals.createConversationAgent('trunk-chan-g2', { serverId: 'stub', channelId: 'world:chan' }); internals.noteInferenceExhausted('trunk-chan-g2', 'compile refused', false, 'over_budget'); internals.disposeConversationAgent('trunk-chan-g2'); internals.surgeryHold = { verb: 'rollback', agentName: 'trunk', since: Date.now() }; @@ -2055,13 +2055,13 @@ describe('queued context maintenance timer', () => { conversations: { templateAgent: 'trunk', bind: { channel: 'mention' }, strategyFactory: () => fork }, }); const internals = framework as unknown as { - createConversationAgent(name: string, channelId: string): Promise; + createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise; disposeConversationAgent(name: string): void; }; const quiet = console.error; console.error = () => {}; try { - await internals.createConversationAgent('trunk-chan-g1', 'world:chan'); + await internals.createConversationAgent('trunk-chan-g1', { serverId: 'stub', channelId: 'world:chan' }); framework.start(); const maintaining = framework.maintenanceTick(); await waitFor(() => fork.ticks === 1); diff --git a/test/mcpl-agent-words.test.ts b/test/mcpl-agent-words.test.ts index c67089f69..57b9f5a75 100644 --- a/test/mcpl-agent-words.test.ts +++ b/test/mcpl-agent-words.test.ts @@ -566,7 +566,7 @@ test("an MCPL tool's error result with no words says so, and that its effect is test("a conversation bound to its channel is told it can't open another, and where to reply", () => { const { framework, events } = frameworkWith(null); - framework.conversationAgentHomes = new Map([['fork', 'home-1']]); + framework.conversationAgentHomes = new Map([['fork', { serverId: 'srv', channelId: 'home-1' }]]); framework.toolLifecycleEmitter = undefined; framework.dispatchChannelToolCall('fork', { id: 'c1', name: 'channel_open', input: { channelId: 'elsewhere' } }); assert.equal(events[0]?.result?.error, diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index f3ed3e4a2..2ea2b5584 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -561,12 +561,12 @@ test('C2 / vector 42: reassigning the server id to another endpoint starts a new test('C3: a replacement follows the prior delivery into its fork, never into a fresh one', async (t) => { const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as never }); t.after(f.close); await f.register(); await f.send('channels/incoming', { messages: [f.channel('c', 'fork_original', { initial: true })] }); - const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined; unbind(id: string): void } }).conversationRouter; - const g1 = router.getBinding('chat')!.agentName; - router.unbind('chat'); + const router = (f.framework as unknown as { conversationRouter: { getBinding(channel: { serverId: string; channelId: string }): { agentName: string } | undefined; unbind(channel: { serverId: string; channelId: string }): void } }).conversationRouter; + const g1 = router.getBinding({ serverId: 'editor', channelId: 'chat' })!.agentName; + router.unbind({ serverId: 'editor', channelId: 'chat' }); const edit = await f.send('push/event', f.params('e', 'fork_edit', { channelId: 'chat', key: 'message:m' })); assert.equal(edit.result.coalesce.outcome, 'replaced'); - assert.equal(router.getBinding('chat'), undefined, 'no fresh fork was spawned for the edit'); + assert.equal(router.getBinding({ serverId: 'editor', channelId: 'chat' }), undefined, 'no fresh fork was spawned for the edit'); const g1ctx = JSON.stringify(f.framework.getAgent(g1)!.getContextManager().getAllMessages()); assert(g1ctx.includes('fork_edit') && !g1ctx.includes('fork_original')); }); @@ -599,8 +599,8 @@ test('G4: a deferred channel-scoped push spawns the conversation fork it needs', f.alwaysRespond(); await f.framework.runUntilIdle(); assert.equal(f.renders.length, 1); - const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined } }).conversationRouter; - const fork = router.getBinding('chat')?.agentName; + const router = (f.framework as unknown as { conversationRouter: { getBinding(channel: { serverId: string; channelId: string }): { agentName: string } | undefined } }).conversationRouter; + const fork = router.getBinding({ serverId: 'editor', channelId: 'chat' })?.agentName; assert(fork, 'a fork was bound'); assert(JSON.stringify(f.framework.getAgent(fork!)!.getContextManager().getAllMessages()).includes('document_diff')); assert(!f.context().includes('document_diff')); @@ -708,14 +708,14 @@ test('R2: work admitted under a former binding never reaches the replacement end test('R3: a deferred notice displacing an unread plain occurrence stays in its fork', async (t) => { const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as never, server: { shouldTriggerInference: () => true } }); t.after(f.close); await f.register(); await f.send('channels/incoming', { messages: [f.channel('c', 'fork_plain', { initial: true })] }); - const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined; unbind(id: string): void } }).conversationRouter; - const g1 = router.getBinding('chat')!.agentName; - router.unbind('chat'); + const router = (f.framework as unknown as { conversationRouter: { getBinding(channel: { serverId: string; channelId: string }): { agentName: string } | undefined; unbind(channel: { serverId: string; channelId: string }): void } }).conversationRouter; + const g1 = router.getBinding({ serverId: 'editor', channelId: 'chat' })!.agentName; + router.unbind({ serverId: 'editor', channelId: 'chat' }); const r = await f.send('push/event', f.params('n', 'fallback', { deferred: true, channelId: 'chat', key: 'message:m' })); assert.equal(r.result.coalesce.outcome, 'replaced'); f.alwaysRespond(); await f.framework.runUntilIdle(); - assert.equal(router.getBinding('chat'), undefined, 'no fresh fork'); + assert.equal(router.getBinding({ serverId: 'editor', channelId: 'chat' }), undefined, 'no fresh fork'); const g1ctx = JSON.stringify(f.framework.getAgent(g1)!.getContextManager().getAllMessages()); assert(g1ctx.includes('document_diff') && !g1ctx.includes('fork_plain')); }); @@ -3361,8 +3361,8 @@ test("#157: a notice that births its subject and is still pending at a restart i assert(isMarked(stored), "the edit is the agent's first view"); }); -type IncomingAnchor = { buildChannelContext: (agent?: string) => { incoming?: { channelId: string; messageId: string } } | undefined }; -const incomingAnchor = (f: Fixture) => (f.framework as unknown as { channelRegistry: IncomingAnchor }).channelRegistry.buildChannelContext('agent')?.incoming; +type IncomingAnchor = { buildChannelContext: (agent: string | undefined, serverId: string) => { incoming?: { channelId: string; messageId: string } } | undefined }; +const incomingAnchor = (f: Fixture) => (f.framework as unknown as { channelRegistry: IncomingAnchor }).channelRegistry.buildChannelContext('agent', 'editor')?.incoming; test("#157: a channel edit that is the agent's first view moves context.incoming to its message, as the message would", async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); diff --git a/test/mcpl-empty-content.test.ts b/test/mcpl-empty-content.test.ts index c2d24f0a5..dc2ba90a5 100644 --- a/test/mcpl-empty-content.test.ts +++ b/test/mcpl-empty-content.test.ts @@ -18,7 +18,7 @@ import { join } from 'node:path'; import type { ContentBlock } from '@animalabs/membrane'; import { AgentFramework } from '../src/index.js'; import { PushHandler, type McplPushEvent } from '../src/mcpl/push-handler.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from '../src/mcpl/visible-content.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -162,7 +162,7 @@ function channelHarness() { (t) => { traces.push(t); }, ); (registry as unknown as { channels: Map }).channels - .set('discord:chan', { serverId: 'discord', descriptor: { id: 'chan', type: 'discord', label: 'chan' }, open: false }); + .set(destinationKey({ serverId: 'discord', channelId: 'chan' }), { serverId: 'discord', descriptor: { id: 'chan', type: 'discord', label: 'chan' }, open: false }); const send = async (...contents: unknown[][]) => { let response: { results: Array<{ messageId: string; accepted: boolean; reason?: string }> } | undefined; await registry.handleIncoming('discord', { diff --git a/test/mcpl-gate-roundtrip.test.ts b/test/mcpl-gate-roundtrip.test.ts index 94ddb9b44..e16625e2b 100644 --- a/test/mcpl-gate-roundtrip.test.ts +++ b/test/mcpl-gate-roundtrip.test.ts @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { EventGate } from '../src/gate/event-gate.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { PushHandler } from '../src/mcpl/push-handler.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; import type { GateConfig } from '../src/gate/types.js'; @@ -48,7 +48,7 @@ function makeRegistry(shouldTriggerInference?: (c: string, m: Record }).channels.set(`zulip:${id}`, { + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'zulip', channelId: id }), { serverId: 'zulip', descriptor: { id, type: 'zulip', label: id }, open: false, }); } @@ -337,7 +337,7 @@ describe('ChannelRegistry durable lifecycle', () => { assert.deepStrictEqual(server.acks, [{ channelId: 'discord:g1:c1', messageId: 'm1', intent: 'seen-not-opening', value: '👀', }]); - assert.strictEqual(registry.isChannelOpen('discord:g1:c1'), false); + assert.strictEqual(registry.isChannelOpen({ serverId: 'discord', channelId: 'discord:g1:c1' }), false); }); it('requires serverId only when two MCPLs expose the same channel id', async () => { @@ -364,7 +364,8 @@ describe('ChannelRegistry durable lifecycle', () => { const ambiguous = await registry.handleChannelToolCall('channel_open', { channelId: 'shared' }); assert.strictEqual(ambiguous.success, false); - assert.match(ambiguous.error ?? '', /^Channel "shared" is on more than one server \("alpha", "beta"\), so nothing ran\./); + assert.equal(ambiguous.error, 'Channel "shared" is on more than one server ("alpha:shared", "beta:shared"), so nothing ran. ' + + 'Call again with channelId set to one of them, or with serverId set to "alpha" or "beta".'); const exact = await registry.handleChannelToolCall('channel_open', { channelId: 'shared', serverId: 'beta', diff --git a/test/mcpl-rejection-log-lines.test.ts b/test/mcpl-rejection-log-lines.test.ts index 561258efc..dabc434af 100644 --- a/test/mcpl-rejection-log-lines.test.ts +++ b/test/mcpl-rejection-log-lines.test.ts @@ -17,7 +17,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fixture, TS } from './helpers/coalescing-fixture.js'; import { logDetail, logValue } from '../src/mcpl/log-value.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { parseDeclaredClasses } from '../src/mcpl/tool-classes.js'; import { RefusalLines } from '../src/mcpl/refusal-lines.js'; @@ -247,10 +247,10 @@ test('speech routed into a closed channel names the channel as one field on each const registry = new ChannelRegistry( { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, {}, ); - (registry as unknown as { channels: Map }).channels.set(`editor:${FORGED_CHANNEL}`, { + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'editor', channelId: FORGED_CHANNEL }), { serverId: 'editor', descriptor: { id: FORGED_CHANNEL, type: 'discord', label: 'chat' }, open: false, }); - const routed = await registry.routeSpeech('agent', 'hello', FORGED_CHANNEL); + const routed = await registry.routeSpeech('agent', 'hello', { serverId: 'editor', channelId: FORGED_CHANNEL }); assert.equal(routed?.delivered, true); assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]')), [ `[routeSpeech] agent: locus ${field(FORGED_CHANNEL)} was closed — opened by delivery (speech implies engagement)`, @@ -262,12 +262,13 @@ test('a refusal reaction with no server for its locus names the locus as one fie const lines = captureConsoleError(t); const locus = `discord:g1:${FORGED_CHANNEL}`; const fakeThis = { - channelRegistry: { buildChannelContext: () => ({ incoming: { channelId: locus, messageId: 'm1' } }), getChannelServerId: () => undefined }, + channelRegistry: { incomingFor: () => ({ channel: { serverId: 'gone', channelId: locus }, messageId: 'm1' }) }, mcplServerRegistry: { getServer: () => undefined }, }; const react = (AgentFramework.prototype as unknown as { reactToRefusal: (agent: string, category: string) => Promise }).reactToRefusal; await react.call(fakeThis, 'agent', 'harmful'); - assert.deepEqual(lines, [`[inference-refusal] reaction skipped: no MCPL server for locus ${field(locus)} (agent=agent)`]); + // A plain token, as a server id is, prints unquoted (logValue). + assert.deepEqual(lines, [`[inference-refusal] reaction skipped: no MCPL server for locus ${field(locus)} on server gone (agent=agent)`]); }); /** The framework's own spawn step, which both spawn paths decide through, diff --git a/test/mcpl-tool-lifecycle-e2e.test.ts b/test/mcpl-tool-lifecycle-e2e.test.ts index 956d8633e..0289fb40d 100644 --- a/test/mcpl-tool-lifecycle-e2e.test.ts +++ b/test/mcpl-tool-lifecycle-e2e.test.ts @@ -214,7 +214,7 @@ describe('tool lifecycle end to end (RFC-007)', () => { const internals = framework as unknown as { mcplServerRegistry: { getServer(id: string): unknown } | null; describeToolForLifecycle(tool: string): { class: string[] }; - conversationAgentHomes: Map; + conversationAgentHomes: Map; }; assert.deepEqual(internals.describeToolForLifecycle('prov--click').class, ['computer'], 'classed while alive'); @@ -243,10 +243,10 @@ describe('tool lifecycle end to end (RFC-007)', () => { assert.equal(after, before, 'a call the host refused (provider gone) produces no events'); }); it('a host-tool refusal (conversation-bound channel guard) is never reported (re-review #1)', async () => { - const internals = framework as unknown as { conversationAgentHomes: Map }; + const internals = framework as unknown as { conversationAgentHomes: Map }; // Make scout a conversation fork bound to one channel: the guard in // dispatchChannelToolCall refuses channel_open before it runs. - internals.conversationAgentHomes.set('scout', 'disc:guild:home'); + internals.conversationAgentHomes.set('scout', { serverId: 'obs', channelId: 'disc:guild:home' }); try { const before = readLog(obsLog).filter((e) => e.event === 'lifecycle').length; const callsBefore = membrane.calls.length; diff --git a/test/mcpl-transport-close.test.ts b/test/mcpl-transport-close.test.ts index 45449f8e7..e8f525974 100644 --- a/test/mcpl-transport-close.test.ts +++ b/test/mcpl-transport-close.test.ts @@ -476,12 +476,12 @@ test("after a transport closes and reconnects, channel_open asks the server agai // transport; the server hasn't registered its channels again yet. connection.transportEpoch++; connection.emit('close', null, null, { transport: 'stdio', code: null, signal: null, byHost: false }); - assert.equal(registry.isChannelOpen('x'), false, "the closed transport's confirmation went with it"); + assert.equal(registry.isChannelOpen({ serverId: 'srv', channelId: 'x' }), false, "the closed transport's confirmation went with it"); connection.transportEpoch++; sent.length = 0; assert.notEqual((await open()).status, 'already open'); assert.deepEqual(sent, ['open x on transport 2'], 'the open goes to the new transport'); - assert.equal(registry.isChannelOpen('x'), true, "the new transport's receipt confirms it"); + assert.equal(registry.isChannelOpen({ serverId: 'srv', channelId: 'x' }), true, "the new transport's receipt confirms it"); }); test("a server that stops reading its stdin without closing it stays connected, and a request ends at its own timeout", { timeout: 20_000 }, async (t) => { diff --git a/test/outgoing-stream.test.ts b/test/outgoing-stream.test.ts index 540a79998..e1c651d3c 100644 --- a/test/outgoing-stream.test.ts +++ b/test/outgoing-stream.test.ts @@ -19,7 +19,7 @@ import type { ContentBlock, Membrane, NormalizedRequest, NormalizedResponse, Str import { AgentFramework, type AgentConfig, type Module, type ModuleContext, type ToolCall, type ToolResult, type ProcessEvent, type ProcessState } from '../src/index.js'; import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { ALL_CAPABILITY_PATHS, CapabilityGrant } from '../src/mcpl/capability-grant.js'; const dirs: string[] = []; @@ -116,9 +116,11 @@ function registry(framework: AgentFramework, options: { streaming?: boolean; ser /** The server a channel belongs to: `options.servers` names some, and the stub's server has the rest. */ const serverOf = (channelId: string) => options.servers?.[channelId] ?? 'stub'; let stopped = false; - /** One post, with the outcome the real registry's deliverSpeech gives it. */ - const publish = async (body: string, channelId: string | null) => { - if (!channelId) return { status: 'failed' as const, reason: 'no channel', at: Date.now() }; + /** One post, with the outcome the real registry's deliverSpeech gives it. + * The framework hands it the channel with its server. */ + const publish = async (body: string, to: { serverId?: string; channelId: string } | null) => { + if (!to) return { status: 'failed' as const, reason: 'no channel', at: Date.now() }; + const channelId = to.channelId; log.push({ kind: 'publish', channelId, text: body }); await holds.get(body); const failure = fails.get(body); @@ -133,14 +135,14 @@ function registry(framework: AgentFramework, options: { streaming?: boolean; ser : { status: 'failed' as const, destination, reason: 'the stub reported delivered: false', at: Date.now() }; }; const target: Record = { - resolveLocus: () => 'world:test', - resolveProseTarget: (spec: string) => (known[spec] ? { channelId: known[spec] } : { error: `no channel ${spec}` }), + resolveLocus: () => ({ serverId: serverOf('world:test'), channelId: 'world:test' }), + resolveProseTarget: (spec: string) => (known[spec] ? { serverId: serverOf(known[spec]), channelId: known[spec] } : { error: `no channel ${spec}` }), resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: serverOf(channelId), channelId } }), // Locus prose posts through routeSpeech, a named or sticky destination // through deliverSpeech, as with the real registry. - deliverSpeech: async (_agent: string, body: string, channelId: string | null) => publish(body, channelId), - routeSpeech: async (_agent: string, body: string, channelId: string | null) => { - const outcome = await publish(body, channelId); + deliverSpeech: async (_agent: string, body: string, to: { serverId?: string; channelId: string } | null) => publish(body, to), + routeSpeech: async (_agent: string, body: string, to: { serverId?: string; channelId: string } | null) => { + const outcome = await publish(body, to); return outcome.status === 'delivered' ? { delivered: true, serverId: outcome.destination.serverId, channelId: outcome.destination.channelId, ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}) } : null; @@ -170,6 +172,8 @@ function registry(framework: AgentFramework, options: { streaming?: boolean; ser : { success: true, data: { noted: true } }), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + channelsNamed: (name: string) => [{ serverId: serverOf(name), channelId: name }], + nameShown: (channel: { channelId: string }) => channel.channelId, }; // A registry whose server doesn't take the stream sends no chunk or complete. if (options.streaming === false) { @@ -631,14 +635,14 @@ test("with the real channel registry, a stop() with a publish still out sends th sendChannelsTyping: () => {}, }; const registry = new ChannelRegistry({ getServer: () => server } as never, {} as never, () => {}, () => {}, {}); - (registry as unknown as { channels: Map }).channels.set('srv:world:test', + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'srv', channelId: 'world:test' }), { serverId: 'srv', descriptor: { id: 'world:test', type: 'srv', label: 'world:test' }, open: true }); const stopAll = registry.stopAll.bind(registry); (registry as unknown as { stopAll: () => void }).stopAll = () => { wire.push('registry stopped'); stopAll(); }; // The turn's locus and channel tools, as the harness gives them. (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { get: (target, key: string) => { - if (key === 'resolveLocus') return () => 'world:test'; + if (key === 'resolveLocus') return () => ({ serverId: 'srv', channelId: 'world:test' }); if (key === 'handleChannelToolCall') return async () => ({ success: true, data: {} }); if (key === 'getChannelTools') return () => []; const value = (target as unknown as Record)[key]; @@ -711,13 +715,14 @@ async function realRegistryTurn( const getServer = (id: string) => servers.get(id) ?? servers.set(id, server(id)).get(id)!; const registry = new ChannelRegistry({ getServer } as never, {} as never, () => {}, () => {}, {}); const open = (serverId: string, channelId: string) => { - (registry as unknown as { channels: Map }).channels.set(`${serverId}:${channelId}`, + (registry as unknown as { channels: Map }).channels.set(destinationKey({ serverId, channelId }), { serverId, descriptor: { id: channelId, type: 'chat', label: channelId }, open: true }); }; for (const [serverId, channelId] of channels) open(serverId, channelId); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(registry, { get: (target, key: string) => { - if (key === 'resolveLocus') return () => 'room'; + // The turn's locus is the first channel, on its server. + if (key === 'resolveLocus') return () => ({ serverId: channels[0]![0], channelId: channels[0]![1] }); if (key === 'handleChannelToolCall') return async () => ({ success: true, data: {} }); if (key === 'getChannelTools') return () => []; const value = (target as unknown as Record)[key]; @@ -747,7 +752,10 @@ test("with the real channel registry, a stream ends in a channel its server decl 'the later text is refused there, and the stream it took ends there'); }); -test("with the real channel registry, a channel id that passes from one server to another mid-turn: each server is streamed only what was published to it", async () => { +test("with the real channel registry, a channel id that passes from one server to another mid-turn: the turn's words stay with its own server's channel, and the other server is sent nothing", async () => { + // The turn's locus is a's room, with its server. b's room is another + // channel with the same id, so the turn's later words aren't sent there: + // they're refused, as a's room is no longer registered. const { wire, traces } = await realRegistryTurn( [round(text('first'), call('one', 'test--read')), final(text('second'))], [['a', 'room']], @@ -756,14 +764,12 @@ test("with the real channel registry, a channel id that passes from one server t open('b', 'room'); }, ); - assert.deepEqual(wire, ['a publish:first', 'a chunk:first', 'b publish:second', 'b chunk:second', 'b complete:second'], - "b's words go to b alone, and a, which no longer registers the channel, gets no complete"); + assert.deepEqual(wire, ['a publish:first', 'a chunk:first'], + 'b is sent nothing, and a, which no longer registers the channel, gets no complete'); assert.deepEqual(traces, [ 'inference:published ["a","room"] first', - 'inference:published ["b","room"] second', 'inference:published_complete ["a","room"] first', - 'inference:published_complete ["b","room"] second', - ], 'in-process consumers get each destination apart, by its server'); + ], 'in-process consumers get the destination by its server'); }); test("with the real channel registry, a chunk follows its publish's destination: a channel that moves to another server as the publish is answered streams none of it there", async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 87530eb63..b9b77cd16 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -38,11 +38,16 @@ import type { } from '../src/index.js'; import type { ContextInjection } from '@animalabs/context-manager'; import { AgentFramework } from '../src/index.js'; +import { destinationKey } from '../src/mcpl/channel-registry.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { MembraneError } from '@animalabs/membrane'; import { JsStore } from '@animalabs/chronicle'; import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; +/** A channel, with its server; the stub registry's channels are all on `stub`. */ +type Ref = { serverId: string; channelId: string }; +const stub = (channelId: string): Ref => ({ serverId: 'stub', channelId }); + // --------------------------------------------------------------------------- // Test module: tools that optionally emit a mid-turn external message // --------------------------------------------------------------------------- @@ -154,17 +159,21 @@ function stubChannelRegistry(framework: AgentFramework) { const explicit: Record = { resolveLocus: () => { locusCalls++; - return `chan-live-${locusCalls}`; + return { serverId: 'stub', channelId: `chan-live-${locusCalls}` }; }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); + routeSpeech: async (_agent: string, text: string, locus?: { serverId: string; channelId: string } | null) => { + routed.push({ text, locus: locus?.channelId ?? null }); // Mirror the real registry's outcome shape (delivery receipts read it). - return locus ? { delivered: true, channelId: locus } : null; + return locus ? { delivered: true, serverId: locus.serverId, channelId: locus.channelId } : null; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // Every channel is the stub's server's: a message whose metadata names + // no server names the stub's channel with that id. + channelsNamed: (name: string) => [{ serverId: 'stub', channelId: name }], + nameShown: (channel: { channelId: string }) => channel.channelId, }; // Everything else driveStream/stop touches (startTyping, stopTyping, // stopAll, ensureChannelRegistered, ...) becomes a no-op via Proxy so the @@ -418,15 +427,15 @@ describe('present while acting', () => { const routed = stubChannelRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; registry.resolveProseTarget = (spec: string) => spec === '#cafe' - ? { channelId: 'discord:g:cafe', label: '#cafe' } + ? { serverId: 'stub', channelId: 'discord:g:cafe', label: '#cafe' } : { error: `no channel matches "${spec}"` }; - registry.deliverSpeech = async (_agent: string, text: string, channelId: string) => { - routed.push({ text, locus: channelId }); - return { status: 'delivered' as const, destination: { serverId: 'stub', channelId }, at: Date.now() }; + registry.deliverSpeech = async (_agent: string, text: string, to: Ref) => { + routed.push({ text, locus: to.channelId }); + return { status: 'delivered' as const, destination: { serverId: to.serverId, channelId: to.channelId }, at: Date.now() }; }; const shown: string[] = []; - registry.startTyping = (channelId: string) => { shown.push(`start ${channelId}`); }; - registry.stopTyping = (channelId?: string) => { shown.push(`stop ${channelId ?? '(all)'}`); }; + registry.startTyping = (channel: Ref) => { shown.push(`start ${channel.channelId}`); }; + registry.stopTyping = (channel?: Ref) => { shown.push(`stop ${channel?.channelId ?? '(all)'}`); }; // The text traces of the turn's first stream, by round. const traced: Array<{ round: number; channelId?: string }> = []; let streams = 0; @@ -587,7 +596,7 @@ describe('present while acting', () => { module.handleToolCall = async (call) => { (framework as unknown as { turnRouting: Map }>; - }).turnRouting.get('assistant')!.engaged.add('discord:guild:portables'); + }).turnRouting.get('assistant')!.engaged.add(destinationKey(stub('discord:guild:portables'))); return origHandle(call); }; @@ -626,7 +635,7 @@ describe('present while acting', () => { module.handleToolCall = async (call) => { (framework as unknown as { turnRouting: Map }>; - }).turnRouting.get('assistant')!.engaged.add('discord:guild:hospital'); + }).turnRouting.get('assistant')!.engaged.add(destinationKey(stub('discord:guild:hospital'))); return origHandle(call); }; @@ -1043,7 +1052,7 @@ describe('present while acting', () => { } else { // The turn sent into #portables, and a follow-up there re-pins the // second boundary (seeded: this module's tools aren't MCPL sends). - internals.turnRouting.get('assistant')!.engaged.add('discord:guild:portables'); + internals.turnRouting.get('assistant')!.engaged.add(destinationKey(stub('discord:guild:portables'))); module.interjection = 'replying on portables'; module.interjectionMetadata = ambient('discord:guild:portables'); } @@ -1307,8 +1316,8 @@ describe('present while acting', () => { const routed = stubChannelRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const shown: string[] = []; - registry.startTyping = (channelId: string) => { shown.push(`start ${channelId}`); }; - registry.stopTyping = (channelId?: string) => { shown.push(`stop ${channelId ?? '(all)'}`); }; + registry.startTyping = (channel: { channelId: string }) => { shown.push(`start ${channel.channelId}`); }; + registry.stopTyping = (channel?: { channelId: string }) => { shown.push(`stop ${channel?.channelId ?? '(all)'}`); }; module.toolDelayMs = 25; module.interjection = 'quick question for you'; module.interjectionMetadata = addressed('discord:dm:antra'); @@ -1415,7 +1424,7 @@ describe('present while acting', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); const framework = await createFramework(); const routed = stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; + type Routing = { locus?: Ref; engaged: Set; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; }; @@ -1423,7 +1432,7 @@ describe('present while acting', () => { // #portables, and left its tool results for the non-streaming // fallback's re-inference. Messages arrive before that starts. const open: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), + locus: stub('chan-turn'), triggerChannel: stub('chan-turn'), engaged: new Set([destinationKey(stub('discord:guild:portables'))]), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); @@ -1444,7 +1453,7 @@ describe('present while acting', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); const framework = await createFramework(); const routed = stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; [field: string]: unknown }; + type Routing = { locus?: Ref; engaged: Set; arrivals: Array<{ messageId: string; channel: Ref }>; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; activeTurnTokens: Map; @@ -1453,7 +1462,7 @@ describe('present while acting', () => { addMessage(participant: string, content: ContentBlock[], metadata: Record, opts?: { bypassDeferralFor?: string }): string; }; const open: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), + locus: stub('chan-turn'), triggerChannel: stub('chan-turn'), engaged: new Set(), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); @@ -1466,7 +1475,7 @@ describe('present while acting', () => { internals.addMessage('Antra', [{ type: 'text', text: 'batched' }] as ContentBlock[], addressed('discord:dm:q'), { bypassDeferralFor: 'assistant' }); internals.assembling.delete('assistant'); internals.activeTurnTokens.delete('assistant'); - assert.deepEqual(open.arrivals.map((a) => a.channelId), ['discord:dm:q']); + assert.deepEqual(open.arrivals.map((a) => a.channel.channelId), ['discord:dm:q']); // A message still deferred when the turn's next start begins lands in // that start's own flush, and is the newest arrival. internals.deferredMessages.push({ @@ -1603,13 +1612,13 @@ describe('present while acting', () => { it("a silent start that throws leaves an open prose turn alone", async () => { const framework = await createFramework(); stubChannelRegistry(framework); - type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; + type Routing = { locus?: Ref; engaged: Set; [field: string]: unknown }; const internals = framework as unknown as { openLogical: Map; pendingRequests: InferenceRequest[]; recordTurnCheckpoint(agentName: string): void; }; const open: Routing = { - locus: 'chan-turn', engaged: new Set(), + locus: stub('chan-turn'), engaged: new Set(), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.openLogical.set('assistant', open); @@ -1630,7 +1639,7 @@ describe('present while acting', () => { const routed = stubChannelRegistry(framework); failStream(1, () => new Error('synthetic transient stream error')); feedStart(framework, 2, [{ type: 'text', text: 'After the retry.' }] as ContentBlock[]); - type Routing = { locus?: string; engaged: Set; [field: string]: unknown }; + type Routing = { locus?: Ref; engaged: Set; [field: string]: unknown }; const internals = framework as unknown as { turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; }; @@ -1638,7 +1647,7 @@ describe('present while acting', () => { // live, and stop() ended every open turn. Its budget restart now runs, // and its stream fails and is retried. const turn: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), + locus: stub('chan-turn'), triggerChannel: stub('chan-turn'), engaged: new Set(), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', turn); @@ -1678,14 +1687,14 @@ describe('present while acting', () => { module.interjection = 'quick question for you'; module.interjectionMetadata = addressed('discord:dm:antra'); feedStart(framework, 2, [{ type: 'text', text: 'quiet' }] as ContentBlock[]); - type Routing = { locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; [field: string]: unknown }; + type Routing = { locus?: Ref; engaged: Set; arrivals: Array<{ messageId: string; channel: Ref }>; [field: string]: unknown }; const internals = framework as unknown as { openLogical: Map; pendingRequests: InferenceRequest[]; }; // Built by hand, as #312's silent-start test does: production runs no // silent turn while a prose turn is open. const open: Routing = { - locus: 'chan-turn', engaged: new Set(), + locus: stub('chan-turn'), engaged: new Set(), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.openLogical.set('assistant', open); @@ -1728,13 +1737,13 @@ describe('present while acting', () => { if ((content[0] as { text?: string })?.text?.includes(notice)) throw new Error('store refused'); return add(participant, content, metadata); }; - const baseline = (framework as unknown as { lastAnnouncedLocus: Map }).lastAnnouncedLocus; + const baseline = (framework as unknown as { lastAnnouncedLocus: Map }).lastAnnouncedLocus; trigger(framework); await framework.runUntilIdle(); if (notice === 'The conversation moved to') { - assert.equal(baseline.get('assistant'), 'chan-live-1', 'the agent was last told of the locus the turn opened at'); + assert.deepEqual(baseline.get('assistant'), stub('chan-live-1'), 'the agent was last told of the locus the turn opened at'); } else { assert.equal(baseline.has('assistant'), false, 'the agent was never told a locus'); } @@ -1754,6 +1763,7 @@ describe('present while acting', () => { framework.lastAnnouncedLocus = new Map(); framework.activeTurnTriggers = new Map(); framework.openLogical = new Map(); + framework.scriptCallStartTurns = new Map(); return framework; } const until = async (done: () => boolean, label: string): Promise => { @@ -1847,9 +1857,9 @@ describe('present while acting', () => { } it("a send's engaged channel is recorded on the turn that sent, however late its open resolves", async () => { - const sending = { engaged: new Set() }; - const next = { engaged: new Set() }; - let resolveOpen!: (outcome: { status: string; channelId: string }) => void; + const sending = { turnId: 41, engaged: new Set(), wasIn: [] }; + const next = { turnId: 42, engaged: new Set(), wasIn: [] }; + let resolveOpen!: (outcome: { status: string; channelId: string; serverId: string }) => void; const framework = bareFramework(); framework.mcplServerRegistry = { getServer: (id: string) => id === 'chat' ? { @@ -1868,16 +1878,18 @@ describe('present while acting', () => { const results: unknown[] = []; framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + // The send is the turn's own call, made inside it. framework.dispatchMcplToolCall('assistant', { - id: 'send-1', name: 'chat--send_message', input: { channelId: 'discord:guild:portables', text: 'hi' }, + id: 'send-1', name: 'chat--send_message', input: { channelId: 'discord:guild:portables', text: 'hi' }, callerTurnId: 41, }, 'chat', 'chat'); await until(() => results.length > 0 && resolveOpen !== undefined, 'the send to finish'); // The turn that sent has ended, and the next turn is the agent's live routing. (framework.turnRouting as Map).set('assistant', next); - resolveOpen({ status: 'already-open', channelId: 'discord:guild:portables' }); + resolveOpen({ status: 'already-open', channelId: 'discord:guild:portables', serverId: 'chat' }); await until(() => sending.engaged.size > 0, 'the open to resolve'); - assert.deepEqual([...sending.engaged], ['discord:guild:portables']); + assert.deepEqual([...sending.engaged], [destinationKey({ serverId: 'chat', channelId: 'discord:guild:portables' })], + "the channel the send went to, on the send tool's server"); assert.deepEqual([...next.engaged], [], "the next turn didn't send there"); }); @@ -1892,10 +1904,10 @@ describe('present while acting', () => { ['moves no locus once its turn has settled, before any later start', 'settled', false], ] as const) { it(`a channel_open whose result lands late ${label}`, async () => { - type Routing = { engaged: Set; triggerChannel?: string; locus?: string }; - const calling: Routing = { engaged: new Set(), locus: 'chan-before' }; - const other: Routing = { engaged: new Set(), locus: 'chan-next' }; - let resolveOpen!: (result: { success: boolean; data: { channelId: string } }) => void; + type Routing = { turnId: number; engaged: Set; wasIn: Ref[]; triggerChannel?: Ref; locus?: Ref }; + const calling: Routing = { turnId: 41, engaged: new Set(), wasIn: [], locus: stub('chan-before') }; + const other: Routing = { turnId: 42, engaged: new Set(), wasIn: [], locus: stub('chan-next') }; + let resolveOpen!: (result: { success: boolean; data: { channelId: string; serverId: string } }) => void; const framework = bareFramework(); framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); framework.channelRegistry = { @@ -1909,8 +1921,9 @@ describe('present while acting', () => { if (event.type === 'tool-result') results.push(event as unknown as { result: { data?: { routing?: string } } }); }; + // The open is the turn's own call, made inside it. framework.dispatchChannelToolCall('assistant', { - id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, callerTurnId: 41, }); await until(() => resolveOpen !== undefined, 'the open to start'); const turnRouting = framework.turnRouting as Map; @@ -1918,28 +1931,29 @@ describe('present while acting', () => { if (after === 'silent start') turnRouting.set('assistant', other); if (after === 'next turn') { turnRouting.set('assistant', other); openLogical.set('assistant', other); } if (after === 'settled') openLogical.delete('assistant'); - resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory' } }); + resolveOpen({ success: true, data: { channelId: 'discord:guild:observatory', serverId: 'discord' } }); await until(() => results.length > 0, 'the tool result'); - assert.equal(calling.triggerChannel, 'discord:guild:observatory', 'the turn that called records the channel it opened'); - assert.equal(calling.locus, moves ? 'discord:guild:observatory' : 'chan-before'); - assert.deepEqual(other, { engaged: new Set(), locus: 'chan-next' }, 'the other routing state is untouched'); - assert.equal((framework.lastAnnouncedLocus as Map).get('assistant'), - moves ? 'discord:guild:observatory' : undefined, "the announce baseline moves with the locus, for a move the agent is told"); + const observatory = { serverId: 'discord', channelId: 'discord:guild:observatory' }; + assert.deepEqual(calling.triggerChannel, observatory, 'the turn that called records the channel it opened, with its server'); + assert.deepEqual(calling.locus, moves ? observatory : stub('chan-before')); + assert.deepEqual(other, { turnId: 42, engaged: new Set(), wasIn: [], locus: stub('chan-next') }, 'the other routing state is untouched'); + assert.deepEqual((framework.lastAnnouncedLocus as Map).get('assistant'), + moves ? observatory : undefined, "the announce baseline moves with the locus, for a move the agent is told"); assert.equal(results[0]!.result.data?.routing !== undefined, moves, 'and the result says so only then'); }); } for (const [label, silent, expected] of [ - ['while its turn runs moves the locus and the announce baseline, and says so', false, 'discord:guild:observatory'], - ['in a silent turn moves no locus, and claims none', true, 'chan-before'], + ['while its turn runs moves the locus and the announce baseline, and says so', false, { serverId: 'discord', channelId: 'discord:guild:observatory' }], + ['in a silent turn moves no locus, and claims none', true, stub('chan-before')], ] as const) { it(`a channel_open ${label}`, async () => { - const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; + const calling: { turnId: number; engaged: Set; wasIn: Ref[]; triggerChannel?: Ref; locus?: Ref } = { turnId: 41, engaged: new Set(), wasIn: [], locus: stub('chan-before') }; const framework = bareFramework(); framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); framework.channelRegistry = { - handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory' } }), + handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory', serverId: 'discord' } }), inboundOnlyServer: () => undefined, }; framework.turnRouting = new Map([['assistant', calling]]); @@ -1951,16 +1965,108 @@ describe('present while acting', () => { }; framework.dispatchChannelToolCall('assistant', { - id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, + id: 'open-1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' }, callerTurnId: 41, }); await until(() => results.length > 0, 'the tool result'); - assert.equal(calling.locus, expected); - assert.equal((framework.lastAnnouncedLocus as Map).get('assistant'), silent ? undefined : 'discord:guild:observatory'); + assert.deepEqual(calling.locus, expected); + assert.deepEqual(calling.wasIn, [{ serverId: 'discord', channelId: 'discord:guild:observatory' }], + 'the opened channel is one the turn has been in, kept after any later move'); + assert.deepEqual((framework.lastAnnouncedLocus as Map).get('assistant'), + silent ? undefined : { serverId: 'discord', channelId: 'discord:guild:observatory' }); assert.equal(results[0]!.result.data?.routing !== undefined, !silent, 'the result tells the agent only of a move it made'); }); } + it("a send made outside the agent's turn engages none of its channels", async () => { + const sending = { turnId: 41, engaged: new Set(), wasIn: [] }; + let resolveOpen!: (outcome: { status: string; channelId: string; serverId: string }) => void; + const framework = bareFramework(); + framework.mcplServerRegistry = { + getServer: (id: string) => id === 'chat' ? { id: 'chat', sendToolsCall: async () => ({ content: [{ type: 'text', text: 'sent' }] }) } : null, + }; + framework.mcplServerConfigs = new Map([['chat', { id: 'chat', toolPrefix: 'chat' }]]); + framework.mcplToolFeatureSets = new Map(); + framework.checkpointManager = null; + framework.channelRegistry = { + openIfClosedForSend: () => new Promise((resolve) => { resolveOpen = resolve; }), + directsLifecycle: () => true, + }; + framework.turnRouting = new Map([['assistant', sending]]); + const results: unknown[] = []; + framework.pushEvent = (event: { type: string }) => { if (event.type === 'tool-result') results.push(event); }; + // An earlier turn's call, such as a script that turn left running. + framework.dispatchMcplToolCall('assistant', { + id: 'send-old', name: 'chat--send_message', input: { channelId: 'discord:guild:portables', text: 'hi' }, callerTurnId: 40, + }, 'chat', 'chat'); + await until(() => results.length > 0 && resolveOpen !== undefined, 'the send to finish'); + resolveOpen({ status: 'already-open', channelId: 'discord:guild:portables', serverId: 'chat' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual([...sending.engaged], [], "the turn didn't send there"); + }); + + it("a sleep announces in the calling turn's locus, and a call made outside the turn announces nowhere", async () => { + const framework = bareFramework(); + const announced: Array<{ text: string; locus: unknown }> = []; + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.eventGate = { setSleep: () => ({ until: Date.now() + 60_000 }) }; + framework.channelRegistry = { + routeSpeech: async (_agent: string, text: string, locus: unknown) => { announced.push({ text, locus }); return { delivered: true }; }, + }; + framework.turnRouting = new Map([['assistant', { turnId: 41, engaged: new Set(), wasIn: [], locus: stub('chan-turn') }]]); + // The turn's own call; one an earlier turn made (a script it left running); one with no turn. + for (const callerTurnId of [41, 40, undefined]) { + framework.dispatchSleepToolCall('assistant', { + id: `sleep-${callerTurnId}`, name: 'sleep', input: { seconds: 60 }, ...(callerTurnId !== undefined ? { callerTurnId } : {}), + }); + } + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(announced.map((a) => a.locus), [stub('chan-turn')], "only the turn's own sleep announces, in its locus"); + }); + + it("a sleep announces in the calling turn's explicit pin, and the open turn's sleep under a silent start announces in the open turn's", async () => { + const framework = bareFramework(); + const announced: unknown[] = []; + framework.eventGate = { setSleep: () => ({ until: Date.now() + 60_000 }) }; + framework.channelRegistry = { + routeSpeech: async (_agent: string, _text: string, to: unknown) => { announced.push(to); return { delivered: true }; }, + }; + const sleep = (callerTurnId: number): void => framework.dispatchSleepToolCall('assistant', { + id: `sleep-${callerTurnId}-${announced.length}`, name: 'sleep', input: { seconds: 60 }, callerTurnId, + }); + const settle = (): Promise => new Promise((resolve) => setImmediate(resolve)); + const open = { turnId: 7, engaged: new Set(), wasIn: [], locus: stub('chan-open'), pin: stub('pin-open') }; + const silent = { turnId: 8, engaged: new Set(), wasIn: [], locus: stub('chan-silent'), pin: stub('pin-silent') }; + for (const mode of ['explicit', 'locus'] as const) { + announced.length = 0; + framework.agents = new Map([['assistant', { proseRouting: mode }]]); + // Turn 7 open and live: its sleep announces in its own pin or locus. + framework.openLogical.set('assistant', open); + framework.turnRouting = new Map([['assistant', open]]); + framework.activeTurnTriggers = new Map([['assistant', {}]]); + sleep(7); + await settle(); + // A silent start's routing is the latest while turn 7 is still open: + // turn 7's sleep is turn 7's, so it announces where turn 7 would; + // the silent start's own sleep announces nowhere. + framework.turnRouting = new Map([['assistant', silent]]); + framework.activeTurnTriggers = new Map([['assistant', { suppressProse: true }]]); + sleep(7); + sleep(8); + await settle(); + const expected = mode === 'explicit' ? stub('pin-open') : stub('chan-open'); + assert.deepEqual(announced, [expected, expected], `${mode}: the open turn's sleep, live and under a silent start; none for the silent start's`); + } + // With prose routing disabled, the turn's own sleep announces nowhere. + announced.length = 0; + framework.agents = new Map([['assistant', { proseRouting: 'disabled' }]]); + framework.turnRouting = new Map([['assistant', open]]); + framework.activeTurnTriggers = new Map([['assistant', {}]]); + sleep(7); + await settle(); + assert.deepEqual(announced, [], 'proseRouting disabled'); + }); + it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => { // Explicit sends receipt themselves via tool_result; auto-routed prose // previously vanished with no in-window trace — the agent could never @@ -2152,8 +2258,8 @@ describe('present while acting', () => { (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); const shown: string[] = []; - registry.startTyping = (channelId: string) => { shown.push(`start ${channelId}`); }; - registry.stopTyping = (channelId?: string) => { shown.push(`stop ${channelId ?? '(all)'}`); }; + registry.startTyping = (channel: { channelId: string }) => { shown.push(`start ${channel.channelId}`); }; + registry.stopTyping = (channel?: { channelId: string }) => { shown.push(`stop ${channel?.channelId ?? '(all)'}`); }; const traced: Array<{ channelId?: string }> = []; framework.onTrace((event) => { const e = event as { type: string; blockType?: string; channelId?: string }; @@ -2196,8 +2302,8 @@ describe('present while acting', () => { (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); const shown: string[] = []; - registry.startTyping = (channelId: string) => { shown.push(`start ${channelId}`); }; - registry.stopTyping = (channelId?: string) => { shown.push(`stop ${channelId ?? '(all)'}`); }; + registry.startTyping = (channel: { channelId: string }) => { shown.push(`start ${channel.channelId}`); }; + registry.stopTyping = (channel?: { channelId: string }) => { shown.push(`stop ${channel?.channelId ?? '(all)'}`); }; trigger(framework); await framework.runUntilIdle(); @@ -2227,8 +2333,8 @@ describe('present while acting', () => { (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); const shown: string[] = []; - registry.startTyping = (channelId: string) => { shown.push(`start ${channelId}`); }; - registry.stopTyping = (...args: unknown[]) => { shown.push(args.length === 0 ? 'stop (all)' : `stop ${String(args[0])}`); }; + registry.startTyping = (channel: { channelId: string }) => { shown.push(`start ${channel.channelId}`); }; + registry.stopTyping = (...args: unknown[]) => { shown.push(args.length === 0 ? 'stop (all)' : `stop ${(args[0] as { channelId: string }).channelId}`); }; trigger(framework); await framework.runUntilIdle(); @@ -2251,7 +2357,7 @@ describe('present while acting', () => { /** The stub registry answers that body:ears is inbound-only, declared by pocket-body. */ function declareEarsInboundOnly(framework: AgentFramework): Record { const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; - registry.inboundOnlyServer = (channelId: string) => (channelId === 'body:ears' ? 'pocket-body' : undefined); + registry.inboundOnlyServer = (channel: { channelId: string }) => (channel.channelId === 'body:ears' ? 'pocket-body' : undefined); return registry; } @@ -2277,8 +2383,8 @@ describe('present while acting', () => { trigger(framework); await framework.runUntilIdle(); - (framework as unknown as { announceLocusIfChanged(agentName: string, locus: string | null): void }) - .announceLocusIfChanged('assistant', 'body:ears'); + (framework as unknown as { announceLocusIfChanged(agentName: string, locus: Ref | null): void }) + .announceLocusIfChanged('assistant', stub('body:ears')); const cm = (framework as unknown as { agents: Map }> } }>; @@ -2490,17 +2596,17 @@ describe('present while acting', () => { const announce = ( framework as unknown as { - announceLocusIfChanged(agentName: string, locus: string | null): void; + announceLocusIfChanged(agentName: string, locus: Ref | null): void; } ).announceLocusIfChanged.bind(framework); - announce('assistant', 'chan-live-1'); + announce('assistant', stub('chan-live-1')); assert.equal(notices.length, 1, 'unchanged locus announced nothing'); - announce('assistant', 'chan-B'); + announce('assistant', stub('chan-B')); assert.equal(notices.length, 2, 'changed locus announced exactly once'); - announce('assistant', 'chan-B'); + announce('assistant', stub('chan-B')); assert.equal(notices.length, 2, 'steady state on the new locus stays silent'); announce('assistant', null); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 05dbd0a30..67c04d84e 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1443,11 +1443,11 @@ describe('drafts resend ownership', () => { resolve: (o) => resolve({ destination: { serverId: 'discord', channelId }, at: Date.now(), ...(o as object) }), })); const stub: Record = { - resolveProseTarget: (spec: string) => ({ channelId: spec }), + resolveProseTarget: (spec: string) => ({ serverId: 'discord', channelId: spec }), resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), publish: (_agent: string, text: string, target: { channelId: string }) => held(text, target.channelId), - // An envelope's delivery, `{{unsent}}` included. - deliverSpeech: (_agent: string, text: string, channelId: string) => held(text, channelId), + // An envelope's delivery, `{{unsent}}` included, to the channel with its server. + deliverSpeech: (_agent: string, text: string, to: { channelId: string }) => held(text, to.channelId), getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { @@ -2022,8 +2022,8 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end ['words for the room', `written for >>>${ROOM}`], ['more for the room', undefined], ]); - assert.equal((h.framework as unknown as { turnRouting: Map }).turnRouting.get('scout')?.pin, ROOM, - 'the named destination is the sticky target again, as live routing would leave it'); + assert.deepEqual((h.framework as unknown as { turnRouting: Map }).turnRouting.get('scout')?.pin, + { serverId: 'discord', channelId: ROOM }, 'the named destination, on its server, is the sticky target again, as live routing would leave it'); assert.match(h.texts().find((t) => t.startsWith('[delivered]'))!, / · 3 plain-speech segment\(s\) kept private \(skip_reply or a private think\)$/); // A live `>>>skip_reply` in an earlier round binds a later silenced one. @@ -2078,6 +2078,20 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end } }); + it("hybrid: a draft a bare {{unsent}} delivers in its own turn is listed once in the receipt, as the plain speech it went out as", async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + try { + fixIds(h, ['d-unsent1']); + // Bounced, then sent by a bare {{unsent}} in the turn's next round. + await h.turn(rounds([text('>>>#nowhere lost words'), call('l1', 'channel_list', {})], [text(`>>>${ROOM} {{unsent}}`), call('l2', 'channel_list', {})])); + assert.deepEqual(h.publishes().map((p) => p.text), ['lost words']); + assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), + `[delivered] plain speech → #room (Guild One) (${ROOM}) · draft d-unsent1 delivered by your {{unsent}}`); + } finally { + await h.close(); + } + }); + it('the turn-end receipt reports each draft as it stands: resent, dismissed, unconfirmed or still held', async () => { const h = await harness(); try { diff --git a/test/provider-acceleration-cooldown.test.ts b/test/provider-acceleration-cooldown.test.ts index d9ae26af3..712e83dcb 100644 --- a/test/provider-acceleration-cooldown.test.ts +++ b/test/provider-acceleration-cooldown.test.ts @@ -143,12 +143,12 @@ test('dynamic conversation forks are excluded from provider cooldown ownership', modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0, }); const internal = framework as unknown as { - conversationAgentHomes: Map; + conversationAgentHomes: Map; providerAccelerationCooldowns: Map; providerGates: Map; holdProviderAcceleration(agent: unknown, error: Error, trigger?: unknown): boolean; }; - internal.conversationAgentHomes.set('resident', 'world:test'); + internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:test' }); try { const error = new MembraneError({ type: 'rate_limit', retryable: true, httpStatus: 429, diff --git a/test/provider-waits.test.ts b/test/provider-waits.test.ts index 7816ecf53..14177c76a 100644 --- a/test/provider-waits.test.ts +++ b/test/provider-waits.test.ts @@ -1011,13 +1011,13 @@ test('stop() cancels a retry wait on the path that does not own provider admissi }, }); const internal = fw as unknown as { - conversationAgentHomes: Map; + conversationAgentHomes: Map; retryWaitWakers: Set; startAgentStream(agent: unknown, trigger: unknown): Promise; }; let stopped = false; try { - internal.conversationAgentHomes.set('resident', 'world:zz'); + internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); const run = internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); for (let i = 0; i < 300 && internal.retryWaitWakers.size === 0; i++) await sleep(10); @@ -1631,7 +1631,7 @@ class FailingAt extends WaitingMembrane { } type NonOwning = Internal & { - conversationAgentHomes: Map; + conversationAgentHomes: Map; retryWaitWakers: Set<() => void>; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number }>; startAgentStream(agent: unknown, trigger: unknown): Promise; @@ -1667,7 +1667,7 @@ async function waitFor(fw: AgentFramework, done: () => boolean, what: string, bo } async function conversationTurn(fw: AgentFramework, internal: NonOwning, text: string): Promise { - internal.conversationAgentHomes.set('resident', 'world:zz'); + internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text }]); await internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); } @@ -2222,7 +2222,7 @@ test("the last boundary holds no agent the framework no longer has: a disposed f const nonOwning = internal as NonOwning; try { const agent = fw.getAgent('resident')!; - nonOwning.conversationAgentHomes.set('resident', 'world:zz'); + nonOwning.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); internal.providerWaits.set('resident', 'zz-model', 60_000, 'zz recorded for the fork'); nonOwning.disposeConversationAgent('resident'); // Its retry wakes after the disposal and reaches the boundary. diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index a3b69fd2a..cc6ece30c 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync, writeFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { McplRequestError } from '../src/mcpl/server-connection.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -59,7 +59,7 @@ function registryWith( channels: Map; }).channels; const seed = (serverId: string, id: string, label: string, open = true) => - channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open }); + channels.set(destinationKey({ serverId, channelId: id }), { serverId, descriptor: { id, type: 'discord', label }, open }); return { registry, published, opened, seed }; } @@ -221,7 +221,7 @@ describe('ChannelRegistry.publish outcomes', () => { discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', data); } }, }, failures, traces); seed('discord', 'c', '#c'); - const outcome = await registry.deliverSpeech('agent', 'words', 'c'); + const outcome = await registry.deliverSpeech('agent', 'words', { serverId: 'discord', channelId: 'c' }); assert.equal(outcome.status, 'unknown'); assert.deepEqual(outcome.detail, data, 'the outcome still carries the complete structured data'); const info = failures[0] as { detail?: string }; @@ -239,7 +239,7 @@ describe('ChannelRegistry.publish outcomes', () => { discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', data); } }, }, failures); seed('discord', 'c', '#c'); - const outcome = await registry.deliverSpeech('agent', 'words', 'c'); + const outcome = await registry.deliverSpeech('agent', 'words', { serverId: 'discord', channelId: 'c' }); assert.equal(outcome.status, 'unknown'); assert.equal(outcome.detail, data, 'the formatter does not change the source value'); assert.equal((failures[0] as { detail?: string }).detail, '[unserializable connector value]'); @@ -277,8 +277,8 @@ describe('ChannelRegistry.publish outcomes', () => { seed('beta', 'discord:g1:room', '#room (Beta)'); const shared = await registry.publish('agent', 'x', { channelId: 'discord:g1:room' }); assert.equal(shared.status, 'failed'); - assert.equal(shared.reason, 'channel id "discord:g1:room" is on more than one server, and no server was named for these ' + - 'words; if you need them there, ask whoever runs this host'); + assert.equal(shared.reason, 'channel id "discord:g1:room" is on more than one server ("alpha:discord:g1:room", ' + + '"beta:discord:g1:room"), and the destination named no server'); assert.equal(published.length, 0, 'never routed through whichever server came first'); const exact = await registry.publish('agent', 'x', { serverId: 'beta', channelId: 'discord:g1:room' }); assert.equal(exact.status, 'delivered'); @@ -299,8 +299,8 @@ describe('ChannelRegistry.publish outcomes', () => { const { registry, seed } = registryWith({ discord: { publish: async () => ({}) }, other: { publish: async () => ({ delivered: false }) } }, failures); seed('discord', 'c', '#c'); seed('other', 'd', '#d'); - assert.equal(await registry.routeSpeech('agent', 'x', 'c'), null); - assert.equal(await registry.routeSpeech('agent', 'x', 'd'), null); + assert.equal(await registry.routeSpeech('agent', 'x', { serverId: 'discord', channelId: 'c' }), null); + assert.equal(await registry.routeSpeech('agent', 'x', { serverId: 'other', channelId: 'd' }), null); assert.deepEqual(failures.map((f) => f.outcome), ['unknown', 'failed']); }); }); diff --git a/test/refusal-reaction-baseline.test.ts b/test/refusal-reaction-baseline.test.ts index 0f602fdac..297148641 100644 --- a/test/refusal-reaction-baseline.test.ts +++ b/test/refusal-reaction-baseline.test.ts @@ -22,18 +22,17 @@ import { /** Drive the private reactToRefusal against a Discord locus, capturing the * emoji it stamps. */ async function emittedFor(category: string): Promise { - const calls: Array<{ tool: string; args: { emoji: string } }> = []; + const calls: Array<{ server: string; tool: string; args: { emoji: string } }> = []; const fakeThis = { channelRegistry: { - buildChannelContext: () => ({ - incoming: { channelId: 'discord:g1:c1', messageId: 'm1' }, - }), - getChannelServerId: () => 'srv1', + incomingFor: () => ({ channel: { serverId: 'srv1', channelId: 'discord:g1:c1' }, messageId: 'm1' }), }, mcplServerRegistry: { - getServer: () => ({ + // Each server's connection records under its own id, so the call shows + // which one reacted: the incoming message's own server. + getServer: (serverId: string) => ({ sendToolsCall: (tool: string, args: { emoji: string }) => { - calls.push({ tool, args }); + calls.push({ server: serverId, tool, args }); return Promise.resolve({}); }, }), @@ -47,6 +46,7 @@ async function emittedFor(category: string): Promise { await react.call(fakeThis, 'tester', category); assert.equal(calls.length, 1, `exactly one reaction for category "${category}"`); assert.equal(calls[0].tool, 'add_reaction'); + assert.equal(calls[0].server, 'srv1', "the reaction goes through the incoming message's own server"); return calls[0].args.emoji; } diff --git a/test/refusals-are-error-results.test.ts b/test/refusals-are-error-results.test.ts index 83f82db52..0ab32c9fe 100644 --- a/test/refusals-are-error-results.test.ts +++ b/test/refusals-are-error-results.test.ts @@ -217,7 +217,7 @@ test('a script, and a module calling in process, get the refusals as errors too' success: false, isError: true, error: TUNED_OUT, - data: { refusal: 'tuned-out', channelId: CHANNEL }, + data: { refusal: 'tuned-out', channelId: CHANNEL, serverId: 'disc' }, }); assert.deepEqual( await keeper.ctx!.callTool({ id: 'ctx-api', name: 'api--lookup', input: {} }), diff --git a/test/reply-server-routes.test.ts b/test/reply-server-routes.test.ts new file mode 100644 index 000000000..b01391a3a --- /dev/null +++ b/test/reply-server-routes.test.ts @@ -0,0 +1,980 @@ +/** + * A message's reply goes back through the server whose message it answers. + * MCPL makes a channel id unique only within one server's connection + * (§14.2), so another server can register the same id, push naming it, or + * hold it alone once the first server withdraws it. These tests drive two + * real servers, `discord` and `slack`, where `slack` registers or names + * discord's channel id, and check which server the agent's words go out + * through: in each prose routing mode, through channel_publish, a drafts + * resend and a script's call, with conversation forks, and for a coalesced + * retraction. Each test prints what it saw with t.diagnostic. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ConversationRouter } from '../src/mcpl/conversation-router.js'; +import type { ChannelRef } from '../src/index.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import type { ToolResult } from '../src/types/events.js'; +import type { PushEventParams, PushEventResult } from '../src/mcpl/types.js'; +import { MockMembrane, HeldStream, createMockResponse } from './helpers/mock-membrane.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); + +async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`timed out waiting for: ${what}`); +} + +describe("a push naming another server's channel, through two real servers", () => { + let dir: string; + let framework: AgentFramework; + let membrane: MockMembrane; + const paths = (id: string) => ({ status: join(dir, `${id}-status.jsonl`), command: join(dir, `${id}-commands.jsonl`) }); + const lines = (path: string): Array> => + existsSync(path) ? readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; + const command = (id: string, c: Record) => appendFileSync(paths(id).command, JSON.stringify(c) + '\n'); + const registry = () => (framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + const rows = (id: string) => registry().listChannelsRaw().filter((e) => e.descriptor.id === id) + .map((e) => ({ serverId: e.serverId, label: e.descriptor.label })); + const published = (text: string) => ['discord', 'slack'].flatMap((id) => + lines(paths(id).status).filter((l) => l.event === 'publish' && String(l.text ?? '').includes(text)).map(() => id)); + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'push-other-server-')); + for (const id of ['discord', 'slack']) writeFileSync(paths(id).command, ''); + membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [ + { id: 'discord', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('discord').status, COMMAND_PATH: paths('discord').command } }, + { id: 'slack', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('slack').status, COMMAND_PATH: paths('slack').command, + CHANNELS: JSON.stringify([{ id: 'slack:T1:C1', label: '#lobby (acme)' }]) } }, + ], + modules: [], + }); + await framework.start(); + await waitFor(() => registry().listChannelsRaw().length >= 3, 'both servers registered'); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("sends nothing through the other server, and leaves its channel as it was", async (t) => { + const before = rows('discord:g1:room'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'cross reply' }])); + command('slack', { op: 'push', eventId: 'ev-cross', origin: { mcplChannelId: 'discord:g1:room', channelName: '#room (Guild One)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'a message pushed by slack' }); + await waitFor(() => published('cross reply').length > 0, 'the reply went out'); + await framework.runUntilIdle(); + t.diagnostic(`turns ${membrane.calls.length}; reply published through ${JSON.stringify(published('cross reply'))}; rows ${JSON.stringify(rows('discord:g1:room'))}`); + assert.deepEqual(published('cross reply'), ['slack'], 'the reply goes out through slack, and nothing through discord'); + assert.deepEqual(rows('discord:g1:room').filter((r) => r.serverId === 'discord'), before, "discord's channel is as it was"); + }); + + it("leaves another server's DM as it was, and answers the push through the server that pushed it", async (t) => { + const DM = 'discord:dm:1548000000000000009'; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'dm reply' }])); + command('discord', { op: 'dm', eventId: 'dm-1', authorId: 'U9', rawChannelId: '1548000000000000009', text: 'a DM with no name', origin: { authorName: '' } }); + await waitFor(() => rows(DM).length > 0, 'the DM placeholder registered'); + await framework.runUntilIdle(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'pushed dm reply' }])); + command('slack', { op: 'push', eventId: 'ev-dm', origin: { mcplChannelId: DM, isDM: true, authorName: 'mallory', authorId: 'U1' }, + tags: ['chat:addressed'], text: 'a message pushed by slack' }); + await waitFor(() => published('pushed dm reply').length > 0, 'the reply went out'); + await framework.runUntilIdle(); + t.diagnostic(`rows ${JSON.stringify(rows(DM))}; pushed dm reply through ${JSON.stringify(published('pushed dm reply'))}`); + assert.deepEqual(published('pushed dm reply'), ['slack'], "slack's push is answered through slack, and nothing through discord"); + assert.deepEqual(rows(DM).filter((r) => r.serverId === 'discord'), [{ serverId: 'discord', label: DM }], "discord's DM keeps its label"); + }); + + it("a coalesced retraction of the pushing server's occurrence is taken though another server registers the id its origin names", async (t) => { + const NEW = 'discord:g1:new'; + const handler = (framework as unknown as { + pushHandler: { handlePushEvent(serverId: string, params: PushEventParams, responder: { respond(r: PushEventResult): void; respondError(code: number, message: string): void }): Promise }; + }).pushHandler; + const send = async (params: Record) => { + const out: { result?: PushEventResult; error?: { code: number; message: string } } = {}; + await handler.handlePushEvent('slack', { featureSet: 'chat', timestamp: new Date().toISOString(), ...params } as unknown as PushEventParams, { + respond: (r) => { out.result = r; }, + respondError: (code, message) => { out.error = { code, message }; }, + }); + return out; + }; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noted' }])); + // slack's occurrence names an id no server registers yet. + const first = await send({ eventId: 'c-1', origin: { mcplChannelId: NEW }, coalesce: { key: 'message:m1' }, + payload: { content: [{ type: 'text', text: 'a coalesced note from slack' }] } }); + // discord registers the id; slack then retracts its occurrence. + await registry().handleRegister('discord', { channels: [{ id: NEW, type: 'chat', label: '#new (Guild One)', direction: 'bidirectional' }] }); + const retract = await send({ eventId: 'c-2', origin: { mcplChannelId: NEW }, coalesce: { key: 'message:m1', retract: true }, + payload: { content: [] } }); + await framework.runUntilIdle(); + t.diagnostic(`first: ${JSON.stringify(first)}; retract: ${JSON.stringify(retract)}; rows ${JSON.stringify(rows(NEW))}`); + assert.equal(first.error, undefined); + assert.equal(retract.error, undefined, 'the retraction is not refused'); + assert.ok(['retracted', 'consumed'].includes(retract.result?.coalesce?.outcome ?? ''), JSON.stringify(retract)); + assert.deepEqual(rows(NEW).filter((r) => r.serverId === 'discord'), [{ serverId: 'discord', label: '#new (Guild One)' }], "discord's channel is as it was"); + }); + + it("still takes a push naming the pushing server's own channel, and answers it there", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'own reply' }])); + command('slack', { op: 'push', eventId: 'ev-own', origin: { mcplChannelId: 'slack:T1:C1', channelName: '#lobby (acme)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'a message pushed by slack' }); + await waitFor(() => published('own reply').length > 0, 'the reply published'); + assert.deepEqual(published('own reply'), ['slack']); + }); +}); + +describe("a turn's replies go out through the server whose message triggered it", () => { + const X = 'discord:g1:room'; + let dir: string; + let framework: AgentFramework; + let membrane: MockMembrane; + let release!: () => void; + let started = 0; + let restoreErrors: () => void = () => {}; + const paths = (id: string) => ({ status: join(dir, `${id}-status.jsonl`), command: join(dir, `${id}-commands.jsonl`) }); + const lines = (path: string): Array> => + existsSync(path) ? readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; + const command = (id: string, c: Record) => appendFileSync(paths(id).command, JSON.stringify(c) + '\n'); + const registry = () => (framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + const holders = (id: string) => registry().listChannelsRaw().filter((e) => e.descriptor.id === id).map((e) => e.serverId).sort(); + const published = (text: string) => ['discord', 'slack'].flatMap((id) => + lines(paths(id).status).filter((l) => l.event === 'publish' && String(l.text ?? '').includes(text)).map(() => id)); + const registerX = (serverId: string, label: string) => + registry().handleRegister(serverId, { channels: [{ id: X, type: 'chat', label, direction: 'bidirectional' }] }); + // A background script's call: no origin and no turn. + const background = async (input: Record): Promise => { + const r = await (framework as unknown as { + dispatchScriptToolCall(agent: string, tool: string, input: Record): Promise; + }).dispatchScriptToolCall('scout', 'channel_publish', input); + return JSON.stringify(r); + }; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'reply-server-')); + for (const id of ['discord', 'slack']) writeFileSync(paths(id).command, ''); + const original = console.error; + console.error = () => {}; + restoreErrors = () => { console.error = original; }; + started = 0; + const released = new Promise((resolve) => { release = resolve; }); + membrane = new MockMembrane(); + const responses: Array> = []; + membrane.pushResponse = (r) => { responses.push(r as ReturnType); }; + membrane.streamYielding = (request) => { + membrane.calls.push(request); + started++; + const turn = responses.splice(0); + return new HeldStream(released, turn.length > 0 ? turn : [createMockResponse([{ type: 'text', text: 'after' }])]); + }; + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [ + { id: 'discord', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('discord').status, COMMAND_PATH: paths('discord').command } }, + { id: 'slack', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('slack').status, COMMAND_PATH: paths('slack').command, + CHANNELS: JSON.stringify([{ id: 'slack:T1:C1', label: '#lobby (acme)' }]) } }, + ], + modules: [], + }); + await framework.start(); + await waitFor(() => registry().listChannelsRaw().length >= 3, 'both servers registered'); + }); + + afterEach(async () => { + release(); + await framework.stop(); + restoreErrors(); + rmSync(dir, { recursive: true, force: true }); + }); + + async function pushX(from: string, eventId: string): Promise { + command(from, { op: 'push', eventId, origin: { mcplChannelId: X, channelName: '#room (Guild One)' }, + tags: ['chat:addressed', 'chat:mention'], text: `a message pushed by ${from}` }); + await waitFor(() => started > 0, 'the turn started'); + } + async function settle(): Promise { + release(); + for (let i = 0; i < 3; i++) { + await framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 300)); + } + } + + // A script's call through the host's bridge, as the isolate hands it over: + // the watching turn, and the turn that started the script. + const scriptCallIn = async (tool: string, input: Record, watchTurn: number, startTurnId: number): Promise => + JSON.stringify(await (framework as unknown as { + scriptToolCall(agent: string, tool: string, argsJson: string, + context: { watch: { turnId: number; endTurn: boolean }; startTurnId: number }): Promise<{ ok: boolean; json: string }>; + }).scriptToolCall('scout', tool, JSON.stringify(input), { watch: { turnId: watchTurn, endTurn: false }, startTurnId })); + const turnNow = () => (framework as unknown as { turnRouting: Map }).turnRouting.get('scout')!; + + for (const own of [false, true]) { + it(`a channel_open from a script ${own ? 'the turn started moves its locus' : 'an earlier turn started moves none of the turn\'s routing'}, so the reply goes ${own ? 'to the opened channel' : 'where its message came from'}`, { timeout: 60_000 }, async (t) => { + const traces: Array<{ type: string; tool?: string }> = []; + framework.onTrace((e) => { traces.push(e as never); }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'reply after the open' }])); + await pushX('discord', 'ev-open'); + const turn = turnNow().turnId; + assert.deepEqual((turnNow() as unknown as { wasIn: unknown[] }).wasIn, [{ serverId: 'discord', channelId: X }], + "the turn's message's channel, its trigger and its locus, is one it has been in"); + // The script's answer comes back through the event queue, so it's read after the turn is released. + const opening = scriptCallIn('channel_open', { channelId: 'slack:T1:C1' }, turn, own ? turn : turn - 1); + await waitFor(() => traces.some((e) => e.tool === 'channel_open' && e.type === 'tool:completed'), 'the open completed'); + t.diagnostic(`locus ${JSON.stringify(turnNow().locus)}`); + release(); + await waitFor(() => published('reply after the open').length > 0, 'the reply went out'); + await settle(); + t.diagnostic(`open: ${(await opening).slice(0, 300)}`); + t.diagnostic(`through ${JSON.stringify(published('reply after the open'))}`); + assert.deepEqual(published('reply after the open'), own ? ['slack'] : ['discord']); + }); + } + + for (const own of [false, true]) { + it(`a drafts resend from a script ${own ? 'the turn started engages its destination, so a follow-up there re-pins the turn' : 'an earlier turn started engages none of the turn\'s channels, so a follow-up there doesn\'t re-pin it'}`, { timeout: 60_000 }, async (t) => { + const { held } = (framework as unknown as { + proseDrafts: { hold(agent: string, segments: Array<{ text: string }>, reason: string): { held: Array<{ id: string }> } }; + }).proseDrafts.hold('scout', [{ text: 'held words' }], 'no-destination'); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'list-1', name: 'channel_list', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'reply after the follow-up' }])); + await pushX('discord', 'ev-engage'); + const turn = turnNow().turnId; + const resending = scriptCallIn('drafts', { action: 'resend', draftIds: [held[0]!.id], destination: 'slack:T1:C1' }, turn, own ? turn : turn - 1); + await waitFor(() => published('held words').length > 0, 'the resend went out'); + command('slack', { op: 'incoming', channelId: 'slack:T1:C1', messageId: 'follow-up', mode: 'ambient', text: 'a follow-up in the lobby' }); + await waitFor(() => { + const d = registry().getDefaultPublishChannel(); + return d?.serverId === 'slack' && d.channelId === 'slack:T1:C1'; + }, 'the follow-up arrived'); + release(); + await waitFor(() => published('reply after the follow-up').length > 0, 'the reply went out'); + await settle(); + const resent = await resending; + t.diagnostic(`resend: ${resent.slice(0, 200)}; locus ${JSON.stringify(turnNow().locus)}; through ${JSON.stringify(published('reply after the follow-up'))}`); + assert.deepEqual(published('reply after the follow-up'), own ? ['slack'] : ['discord']); + }); + } + + it('a server that registers the channel too, pushes, and withdraws it before the reply gets no reply through the other server', async () => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'withdrawn reply' }])); + await pushX('slack', 'ev-withdraw'); + await registry().handleChanged('slack', { removed: [X] }); + assert.deepEqual(holders(X), ['discord']); + await settle(); + assert.deepEqual(published('withdrawn reply'), [], 'nothing goes out through discord'); + }); + + it("a reply isn't sent through a server that registers the channel only after the triggering server pushed it and left", async () => { + registry().removeServer('discord'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'left reply' }])); + await pushX('slack', 'ev-left'); + await registerX('discord', '#room (Guild One)'); + registry().removeServer('slack'); + await settle(); + assert.deepEqual(published('left reply'), []); + }); + + it('the same goes for a message on the channels lane: a server that registers the channel, sends into it and withdraws it gets no reply through the other server', async () => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'lane reply' }])); + command('slack', { op: 'incoming', channelId: X, messageId: 'in-1', mode: 'addressed', text: 'a message slack sent' }); + await waitFor(() => started > 0, 'the turn started'); + await registry().handleChanged('slack', { removed: [X] }); + await settle(); + assert.deepEqual(published('lane reply'), []); + }); + + for (const pusher of ['slack', 'discord'] as const) { + it(`where both servers register one channel, the reply to ${pusher}'s push goes back through ${pusher}`, async () => { + await registerX('slack', '#room (slack)'); + assert.deepEqual(holders(X), ['discord', 'slack']); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `${pusher} reply` }])); + await pushX(pusher, `ev-both-${pusher}`); + await settle(); + assert.deepEqual(published(`${pusher} reply`), [pusher]); + }); + } + + it("a background script's channel_publish naming a shared id goes nowhere, and through the server it names", async (t) => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'turn reply' }])); + await pushX('slack', 'ev-bg'); + await settle(); + assert.deepEqual(published('turn reply'), ['slack']); + const shared = await background({ channelId: X, content: 'background note' }); + const named = await background({ channelId: X, serverId: 'discord', content: 'background note to discord' }); + await new Promise((r) => setTimeout(r, 500)); + t.diagnostic(`shared: ${shared}`); + t.diagnostic(`named: ${named}`); + assert.deepEqual(published('background note'), ['discord'], 'only the call that names discord reached it'); + assert.deepEqual(published('background note to discord'), ['discord'], 'a serverId it names is the server it goes through'); + }); + + it("a script started in one turn and waited on in a later turn from the other server takes the later turn's channel for none of its names", async (t) => { + await registerX('slack', '#room (slack)'); + const routing = () => (framework as unknown as { turnRouting: Map }).turnRouting.get('scout')!; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first reply' }])); + await pushX('discord', 'ev-turn-1'); + const first = routing().turnId; + await settle(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'second reply' }])); + command('slack', { op: 'push', eventId: 'ev-turn-2', origin: { mcplChannelId: X }, tags: ['chat:addressed', 'chat:mention'], text: 'from slack' }); + await waitFor(() => started > 1, 'the second turn started'); + await settle(); + const second = routing().turnId; + assert.notEqual(second, first); + assert.deepEqual([published('first reply'), published('second reply')], [['discord'], ['slack']]); + // A script's call as a later turn's wait carries it, through the host's + // bridge as the isolate hands it over: the watching turn's id, with the + // turn the script was started in. + const scriptCall = async (startTurnId: number, content: string) => JSON.stringify(await (framework as unknown as { + scriptToolCall(agent: string, tool: string, argsJson: string, + context: { watch: { turnId: number; endTurn: boolean }; startTurnId: number }): Promise<{ ok: boolean; json: string }>; + }).scriptToolCall('scout', 'channel_publish', JSON.stringify({ channelId: X, content }), { watch: { turnId: second, endTurn: false }, startTurnId })); + const fromFirst = await scriptCall(first, 'from the first turn\'s script'); + const fromSecond = await scriptCall(second, 'from the second turn\'s script'); + await new Promise((r) => setTimeout(r, 500)); + t.diagnostic(`first turn's script: ${fromFirst}`); + t.diagnostic(`second turn's script: ${fromSecond}`); + assert.deepEqual(published("from the first turn's script"), [], "the second turn's channel isn't the first turn's script's"); + assert.match(fromFirst, /is on more than one server/); + assert.deepEqual(published("from the second turn's script"), ['slack'], "a script in its own turn takes that turn's channel"); + }); + + it("a background script's channel_publish naming no channel goes through the server whose message set that default, or the server it names, and nowhere once that server withdrew the channel", async (t) => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'lane reply' }])); + command('slack', { op: 'incoming', channelId: X, messageId: 'in-1', mode: 'addressed', text: 'a message slack sent' }); + await waitFor(() => started > 0, 'the turn started'); + await settle(); + assert.deepEqual(registry().getDefaultPublishChannel(), { serverId: 'slack', channelId: X }); + const whileShared = await background({ content: 'default while shared' }); + const namedDiscord = await background({ serverId: 'discord', content: 'default named discord' }); + await registry().handleChanged('slack', { removed: [X] }); + const gone = await background({ content: 'default after withdrawal' }); + const goneNamed = await background({ serverId: 'slack', content: 'default named slack after withdrawal' }); + await new Promise((r) => setTimeout(r, 500)); + for (const [k, v] of Object.entries({ whileShared, namedDiscord, gone, goneNamed })) t.diagnostic(`${k}: ${v}`); + assert.deepEqual(published('default while shared'), ['slack'], "the default's own server"); + assert.deepEqual(published('default named discord'), ['discord'], 'a serverId with no channelId names the default\'s id on that server'); + assert.deepEqual(published('default after withdrawal'), [], 'nothing through the server left holding the id'); + assert.deepEqual(published('default named slack after withdrawal'), []); + // Neither refusal offers a way on: the only channel left with the id is + // another server's, not where the default's message came from. + assert.equal((JSON.parse(gone) as ToolResult).error, 'The channel this would default to, "slack:discord:g1:room", isn\'t registered now, so nothing was posted.'); + assert.equal((JSON.parse(goneNamed) as ToolResult).error, + 'Server "slack" has no channel "discord:g1:room", the id of the channel this call would default to, so nothing was posted.'); + }); + + for (const withdraws of [true, false]) { + it(`a channel_publish naming no channel, inside a turn, into a default another message set mid-turn goes through that message's server${withdraws ? ', and nowhere once it withdrew the channel' : ''}`, async () => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'pub-default', name: 'channel_publish', input: { content: 'in-turn default' } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + command('slack', { op: 'incoming', channelId: 'slack:T1:C1', messageId: 'in-lobby', mode: 'addressed', text: 'on the lobby' }); + await waitFor(() => started > 0, 'the turn started'); + command('slack', { op: 'incoming', channelId: X, messageId: 'in-ambient', mode: 'ambient', text: 'in passing' }); + await waitFor(() => registry().getDefaultPublishChannel()?.channelId === X, 'the shared channel is the default'); + assert.deepEqual(registry().getDefaultPublishChannel(), { serverId: 'slack', channelId: X }); + if (withdraws) await registry().handleChanged('slack', { removed: [X] }); + await settle(); + assert.deepEqual(published('in-turn default'), withdraws ? [] : ['slack']); + }); + } + + for (const withdraws of [true, false]) { + it(`a turn with no trigger speaks into the latest message's channel through that message's server${withdraws ? ', and nowhere once it withdrew the channel' : ''}`, async () => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'lane reply' }])); + command('slack', { op: 'incoming', channelId: X, messageId: 'in-1', mode: 'addressed', text: 'a message slack sent' }); + await waitFor(() => started > 0, 'the turn started'); + await settle(); + assert.deepEqual(published('lane reply'), ['slack']); + if (withdraws) await registry().handleChanged('slack', { removed: [X] }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nudged reply' }])); + framework.nudgeAgent('scout', 'a nudge with no channel'); + await waitFor(() => started > 1, 'the nudged turn started'); + await settle(); + assert.deepEqual(published('nudged reply'), withdraws ? [] : ['slack']); + }); + } + + it("a turn batching both servers' messages in one shared channel replies there through one server at most", async (t) => { + const pushed: Array<{ type: string; source?: string; messageId?: string }> = []; + framework.onTrace((e) => { if ((e as { type: string }).type === 'message:added') pushed.push(e as never); }); + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first reply' }])); + command('slack', { op: 'push', eventId: 'ev-first', origin: { mcplChannelId: 'slack:T1:C1', channelName: '#lobby (acme)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'first' }); + await waitFor(() => started > 0, 'the first turn started'); + command('discord', { op: 'push', eventId: 'ev-d', origin: { mcplChannelId: X }, tags: ['chat:addressed', 'chat:mention'], text: 'from discord' }); + command('slack', { op: 'push', eventId: 'ev-s', origin: { mcplChannelId: X }, tags: ['chat:addressed', 'chat:mention'], text: 'from slack' }); + const sent = (id: string, eventId: string) => lines(paths(id).status).some((l) => l.event === 'push-sent' && l.eventId === eventId); + await waitFor(() => sent('discord', 'ev-d') && sent('slack', 'ev-s'), 'both servers pushed'); + // The host has taken both pushes in, so the first turn's release finds them together. + await waitFor(() => pushed.filter((e) => e.source === 'mcpl:push-event').length >= 3, 'the host took both pushes in'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'batched reply' }])); + await settle(); + await waitFor(() => started > 1 && published('batched reply').length > 0, 'the batched turn replied'); + await settle(); + t.diagnostic(`turns ${started}; batched reply through ${JSON.stringify(published('batched reply'))}`); + assert.deepEqual(published('first reply'), ['slack']); + assert.equal(started, 2, 'one turn took both pushes'); + assert.equal(published('batched reply').length, 1, 'the batched reply goes through one server'); + }); + + it("a channel_publish naming the trigger's shared id goes through the trigger's server; another shared id takes the server the call names, and is refused without one", async (t) => { + await registerX('slack', '#room (slack)'); + await registry().handleRegister('slack', { channels: [{ id: 'discord:g1:general', type: 'chat', label: '#general (slack)', direction: 'bidirectional' }] }); + await registry().handleRegister('discord', { channels: [{ id: 'discord:g1:only', type: 'chat', label: '#only (Guild One)', direction: 'bidirectional' }] }); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'pub-1', name: 'channel_publish', input: { channelId: X, content: 'named reply' } }, + { type: 'tool_use', id: 'pub-2', name: 'channel_publish', input: { channelId: 'discord:g1:general', content: 'other reply' } }, + { type: 'tool_use', id: 'pub-3', name: 'channel_publish', input: { channelId: 'discord:g1:general', serverId: 'slack', content: 'chosen reply' } }, + { type: 'tool_use', id: 'pub-4', name: 'channel_publish', input: { channelId: 'discord:g1:only', serverId: 'slack', content: 'misnamed reply' } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'after the tools' }])); + await pushX('slack', 'ev-tool'); + await settle(); + const results = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((c) => c.type === 'tool_result') + .map((c) => JSON.stringify(c)); + for (const r of results) t.diagnostic(r.slice(0, 600)); + t.diagnostic(`named ${JSON.stringify(published('named reply'))} other ${JSON.stringify(published('other reply'))} chosen ${JSON.stringify(published('chosen reply'))} misnamed ${JSON.stringify(published('misnamed reply'))}`); + assert.deepEqual(published('named reply'), ['slack'], "the trigger's id means the trigger's channel"); + assert.deepEqual(published('other reply'), []); + assert.ok(results.some((r) => r.includes('Channel \\"discord:g1:general\\" is on more than one server')), 'a shared id that is no channel of the turn is refused'); + assert.deepEqual(published('chosen reply'), ['slack'], 'the server the call names'); + assert.deepEqual(published('misnamed reply'), [], 'a named server that does not register the id is held to, not passed over'); + }); + + it("a channel_publish naming the trigger's id, once the trigger's server withdrew it, is refused rather than sent through the other server", async (t) => { + await registerX('slack', '#room (slack)'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'pub-gone', name: 'channel_publish', input: { channelId: X, content: 'gone reply' } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + await pushX('slack', 'ev-tool-gone'); + await registry().handleChanged('slack', { removed: [X] }); + await settle(); + const results = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((c) => c.type === 'tool_result').map((c) => JSON.stringify(c)); + for (const r of results) t.diagnostic(r.slice(0, 400)); + assert.deepEqual(published('gone reply'), []); + assert.ok(results.some((r) => r.includes( + '\\"discord:g1:room\\" is this turn\'s channel on server \\"slack\\", which no longer registers it, so nothing was posted, ' + + 'and nothing can be until that server registers it again.')), results.join('\n')); + }); +}); + +describe('in explicit and hybrid mode, a reply goes out through no server but the one whose message it answers', () => { + const X = 'discord:g1:room'; + let dir: string; + let framework: AgentFramework | undefined; + let membrane: MockMembrane; + let release: () => void = () => {}; + let started = 0; + let restoreErrors: () => void = () => {}; + const paths = (id: string) => ({ status: join(dir, `${id}-status.jsonl`), command: join(dir, `${id}-commands.jsonl`) }); + const lines = (path: string): Array> => + existsSync(path) ? readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; + const command = (id: string, c: Record) => appendFileSync(paths(id).command, JSON.stringify(c) + '\n'); + const registry = () => (framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + const published = (text: string) => ['discord', 'slack'].flatMap((id) => + lines(paths(id).status).filter((l) => l.event === 'publish' && String(l.text ?? '').includes(text)).map(() => id)); + + async function start(proseRouting: 'explicit' | 'hybrid'): Promise { + dir = mkdtempSync(join(tmpdir(), 'reply-mode-')); + for (const id of ['discord', 'slack']) writeFileSync(paths(id).command, ''); + const original = console.error; + console.error = () => {}; + restoreErrors = () => { console.error = original; }; + started = 0; + const released = new Promise((resolve) => { release = resolve; }); + membrane = new MockMembrane(); + const responses: Array> = []; + membrane.pushResponse = (r) => { responses.push(r as ReturnType); }; + membrane.streamYielding = (request) => { + membrane.calls.push(request); + started++; + const turn = responses.splice(0); + return new HeldStream(released, turn.length > 0 ? turn : [createMockResponse([])]); + }; + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting }], + mcplServers: [ + { id: 'discord', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('discord').status, COMMAND_PATH: paths('discord').command } }, + { id: 'slack', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('slack').status, COMMAND_PATH: paths('slack').command, + CHANNELS: JSON.stringify([{ id: 'slack:T1:C1', label: '#lobby (acme)' }]) } }, + ], + modules: [], + }); + await framework.start(); + traces.length = 0; + framework.onTrace((e) => { traces.push(e as { type: string; tool?: string }); }); + await waitFor(() => registry().listChannelsRaw().length >= 3, 'both servers registered'); + await registry().handleRegister('slack', { channels: [{ id: X, type: 'chat', label: '#room (slack)', direction: 'bidirectional' }] }); + } + const traces: Array<{ type: string; tool?: string }> = []; + // Where a reply's words ended: published through a server, held as a + // draft by a bounce, or refused at their route. + const ended = (text: string) => (): boolean => published(text).length > 0 + || (framework as unknown as { proseDrafts: { open(agent: string): unknown[] } }).proseDrafts.open('scout').some((d) => JSON.stringify(d).includes(text)) + || traces.some((e) => e.type === 'mcpl:speech-route-failed'); + // A tool call of the turn's answered. + const answered = (tool: string) => (): boolean => traces.some((e) => e.tool === tool && e.type !== 'tool:started'); + + afterEach(async () => { + release(); + await framework?.stop(); + framework = undefined; + restoreErrors(); + rmSync(dir, { recursive: true, force: true }); + }); + + // slack pushes into X, and one server withdraws X while the turn is held; + // released, the turn runs until `done` says its words have ended somewhere. + async function replyAfter(leaves: 'slack' | 'discord', done: () => boolean): Promise { + command('slack', { op: 'push', eventId: `ev-${leaves}`, origin: { mcplChannelId: X, channelName: '#room (slack)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'a message pushed by slack' }); + await waitFor(() => started > 0, 'the turn started'); + await registry().handleChanged(leaves, { removed: [X] }); + release(); + await waitFor(done, "the turn's words ended somewhere"); + await framework!.runUntilIdle(); + } + + // The target names the channel of slack's message by its id, which the + // turn's own channel answers to, or by its server-qualified name. + for (const target of [X, `slack:${X}`]) { + for (const leaves of ['slack', 'discord'] as const) { + const what = leaves === 'slack' ? 'goes nowhere once slack withdrew the channel' : 'goes through slack once discord withdrew it'; + const where = leaves === 'slack' ? [] : ['slack']; + + it(`explicit mode: a >> envelope naming ${target} ${what}`, async (t) => { + await start('explicit'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `>>${target} envelope reply` }])); + await replyAfter(leaves, ended('envelope reply')); + t.diagnostic(`through ${JSON.stringify(published('envelope reply'))}`); + assert.deepEqual(published('envelope reply'), where); + }); + + it(`hybrid mode: a >>> envelope naming ${target} ${what}`, async (t) => { + await start('hybrid'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `>>>${target} hybrid reply` }])); + await replyAfter(leaves, ended('hybrid reply')); + t.diagnostic(`through ${JSON.stringify(published('hybrid reply'))}`); + assert.deepEqual(published('hybrid reply'), where); + }); + + it(`hybrid mode: unprefixed speech after a target-only >>> envelope naming ${target} ${what}`, async (t) => { + await start('hybrid'); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: `>>>${target}` }, + { type: 'tool_use', id: 'list-1', name: 'channel_list', input: {} }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'sticky reply' }])); + await replyAfter(leaves, ended('sticky reply')); + t.diagnostic(`through ${JSON.stringify(published('sticky reply'))}`); + assert.deepEqual(published('sticky reply'), where); + }); + + it(`explicit mode: a drafts resend into ${target}, inside the turn, ${what}`, async (t) => { + await start('explicit'); + const { held } = (framework as unknown as { + proseDrafts: { hold(agent: string, segments: Array<{ text: string }>, reason: string): { held: Array<{ id: string }> } }; + }).proseDrafts.hold('scout', [{ text: 'held words' }], 'no-destination'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'resend-1', name: 'drafts', input: { action: 'resend', draftIds: [held[0]!.id], destination: target } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + await replyAfter(leaves, answered('drafts')); + t.diagnostic(`through ${JSON.stringify(published('held words'))}`); + assert.deepEqual(published('held words'), where); + }); + } + } + + it("explicit mode: a >> envelope in a round that also calls a tool, naming the message's channel, goes nowhere once slack withdrew it", async (t) => { + await start('explicit'); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: `>>${X} in the tool round` }, + { type: 'tool_use', id: 'list-1', name: 'channel_list', input: {} }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + await replyAfter('slack', ended('in the tool round')); + t.diagnostic(`through ${JSON.stringify(published('in the tool round'))}`); + assert.deepEqual(published('in the tool round'), []); + }); + + // A channel_open of another channel moves the turn's trigger there, and in + // hybrid mode its locus; the channel the turn's message came from stays the + // turn's, so its id still means that channel. + it("explicit mode: a >> envelope naming the message's channel, after the turn opened another channel, goes nowhere once slack withdrew it", async (t) => { + await start('explicit'); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'open-1', name: 'channel_open', input: { channelId: 'slack:T1:C1' } }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `>>${X} after the open` }])); + const traces: Array<{ type: string; tool?: string; error?: string }> = []; + framework!.onTrace((e) => { traces.push(e as never); }); + await replyAfter('slack', ended('after the open')); + const opened = traces.find((e) => e.tool === 'channel_open' && e.type !== 'tool:started'); + t.diagnostic(`open ${opened?.type} ${opened?.error ?? ''}; through ${JSON.stringify(published('after the open'))}`); + assert.equal(opened?.type, 'tool:completed', 'the turn opened its other channel'); + assert.deepEqual(published('after the open'), []); + }); + + it("hybrid mode: a >>> envelope the turn wrote before it opened another channel goes nowhere once slack withdrew the channel after the open", async (t) => { + await start('hybrid'); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: `>>>${X} written before the open` }, + { type: 'tool_use', id: 'open-1', name: 'channel_open', input: { channelId: 'slack:T1:C1' } }, + ], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + const traces: Array<{ type: string; tool?: string; error?: string }> = []; + framework!.onTrace((e) => { + traces.push(e as never); + const done = e as { type: string; tool?: string }; + if (done.type === 'tool:completed' && done.tool === 'channel_open') void registry().handleChanged('slack', { removed: [X] }); + }); + command('slack', { op: 'push', eventId: 'ev-before', origin: { mcplChannelId: X, channelName: '#room (slack)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'a message pushed by slack' }); + await waitFor(() => started > 0, 'the turn started'); + release(); + await waitFor(() => !registry().listChannelsRaw().some((e) => e.serverId === 'slack' && e.descriptor.id === X), 'slack withdrew the channel'); + for (let i = 0; i < 3; i++) { + await framework!.runUntilIdle(); + await new Promise((r) => setTimeout(r, 300)); + } + const failed = (traces as Array<{ type: string; serverId?: string; channelId?: string }>).filter((e) => e.type === 'mcpl:speech-route-failed'); + t.diagnostic(`failed ${JSON.stringify(failed)}; through ${JSON.stringify(published('written before the open'))}`); + assert.deepEqual(published('written before the open'), []); + assert.deepEqual(failed.map((e) => [e.serverId, e.channelId]), [['slack', X]], "its delivery was for slack's channel, refused there"); + }); + + it("hybrid mode: a channel_open naming the message's channel id, once slack withdrew it, is refused, and the turn's speech goes through no other server", async (t) => { + await start('hybrid'); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'open-x', name: 'channel_open', input: { channelId: X } }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'after the reopen' }])); + const traces: Array<{ type: string; tool?: string; error?: string }> = []; + framework!.onTrace((e) => { traces.push(e as never); }); + await replyAfter('slack', ended('after the reopen')); + const opened = traces.find((e) => e.tool === 'channel_open' && e.type !== 'tool:started'); + t.diagnostic(`open ${opened?.type} ${opened?.error ?? ''}; through ${JSON.stringify(published('after the reopen'))}`); + assert.equal(opened?.type, 'tool:failed'); + assert.equal(opened?.error, `"${X}" is this turn's channel on server "slack", which no longer registers it, so nothing ran, ` + + 'and nothing can run on it until that server registers it again.'); + assert.deepEqual(published('after the reopen'), []); + }); + + it("hybrid mode: a >>> envelope naming the channel a mid-turn message moved the turn to goes nowhere once that message's server withdrew it", async (t) => { + await start('hybrid'); + // The turn is slack's, on its own lobby. While it's held, an addressed + // message of slack's on the shared channel arrives, and slack withdraws + // that channel; the turn's tool round then moves its locus there, and + // its next round names the channel by its id. + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'list-1', name: 'channel_list', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `>>>${X} re-pinned reply` }])); + command('slack', { op: 'incoming', channelId: 'slack:T1:C1', messageId: 'in-lobby', mode: 'addressed', text: 'on the lobby' }); + await waitFor(() => started > 0, 'the turn started'); + // The held turn's own routing state, which a later turn's doesn't replace. + const routing = (framework as unknown as { turnRouting: Map }).turnRouting.get('scout'); + command('slack', { op: 'incoming', channelId: X, messageId: 'in-x', mode: 'addressed', text: 'over here' }); + await waitFor(() => { + const d = registry().getDefaultPublishChannel(); + return d?.serverId === 'slack' && d.channelId === X; + }, "slack's message on the shared channel arrived"); + await registry().handleChanged('slack', { removed: [X] }); + release(); + for (let i = 0; i < 3; i++) { + await framework!.runUntilIdle(); + await new Promise((r) => setTimeout(r, 300)); + } + const locus = routing?.locus; + t.diagnostic(`locus ${JSON.stringify(locus)}; wasIn ${JSON.stringify(routing?.wasIn)}; through ${JSON.stringify(published('re-pinned reply'))}`); + assert.deepEqual(locus, { serverId: 'slack', channelId: X }, 'the locus moved to slack\'s channel'); + assert.deepEqual(routing?.wasIn, [{ serverId: 'slack', channelId: 'slack:T1:C1' }, { serverId: 'slack', channelId: X }], + 'the turn keeps the channel it opened in and the one it moved to'); + assert.deepEqual(published('re-pinned reply'), []); + }); +}); + +describe("a script's call, through a real code_execution run", () => { + const X = 'discord:g1:room'; + let dir: string; + let framework: AgentFramework; + let membrane: MockMembrane; + let restoreErrors: () => void = () => {}; + const paths = (id: string) => ({ status: join(dir, `${id}-status.jsonl`), command: join(dir, `${id}-commands.jsonl`) }); + const lines = (path: string): Array> => + existsSync(path) ? readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; + const command = (id: string, c: Record) => appendFileSync(paths(id).command, JSON.stringify(c) + '\n'); + const registry = () => (framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + const published = (text: string) => ['discord', 'slack'].flatMap((id) => + lines(paths(id).status).filter((l) => l.event === 'publish' && String(l.text ?? '').includes(text)).map(() => id)); + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'reply-script-')); + for (const id of ['discord', 'slack']) writeFileSync(paths(id).command, ''); + const original = console.error; + console.error = () => {}; + restoreErrors = () => { console.error = original; }; + membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [ + { id: 'discord', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('discord').status, COMMAND_PATH: paths('discord').command } }, + { id: 'slack', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('slack').status, COMMAND_PATH: paths('slack').command, + CHANNELS: JSON.stringify([{ id: 'slack:T1:C1', label: '#lobby (acme)' }]) } }, + ], + modules: [], + codeExecution: { enabled: true }, + }); + await framework.start(); + await waitFor(() => registry().listChannelsRaw().length >= 3, 'both servers registered'); + await registry().handleRegister('slack', { channels: [{ id: X, type: 'chat', label: '#room (slack)', direction: 'bidirectional' }] }); + }); + + afterEach(async () => { + await framework.stop(); + restoreErrors(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a script the turn runs takes the turn's channel for the id of the channel its message came from", async (t) => { + const code = `await tools.channel_publish({ channelId: ${JSON.stringify(X)}, content: 'from the script' }); return 1;`; + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'ce-1', name: 'code_execution', input: { code } }], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + command('slack', { op: 'push', eventId: 'ev-script', origin: { mcplChannelId: X, channelName: '#room (slack)' }, + tags: ['chat:addressed', 'chat:mention'], text: 'a message pushed by slack' }); + const blocks = () => framework.getAgent('scout')!.getContextManager().getAllMessages().flatMap((m) => m.content); + await waitFor(() => blocks().some((c) => c.type === 'tool_result' && (c as { toolName?: string }).toolName === 'code_execution'), + 'the script finished', 45_000); + await framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 300)); + const result = blocks().find((c) => c.type === 'tool_result' && (c as { toolName?: string }).toolName === 'code_execution'); + t.diagnostic(JSON.stringify(result).slice(0, 600)); + assert.deepEqual(published('from the script'), ['slack']); + }); +}); + +describe("with conversation routing, each server's channel is its own conversation", () => { + const X = 'discord:g1:room'; + let dir: string; + let framework: AgentFramework | undefined; + let restoreErrors: () => void = () => {}; + const paths = (id: string) => ({ status: join(dir, `${id}-status.jsonl`), command: join(dir, `${id}-commands.jsonl`) }); + const command = (id: string, c: Record) => appendFileSync(paths(id).command, JSON.stringify(c) + '\n'); + const registry = () => (framework as unknown as { channelRegistry: ChannelRegistry }).channelRegistry; + const holders = (text: string) => [...(framework as unknown as { agents: Map }> } }> }).agents.values()] + .filter((a) => a.name !== 'scout' && a.getContextManager().getAllMessages().some((m) => m.content.some((c) => c.type === 'text' && (c.text ?? '').includes(text)))) + .map((a) => a.name); + + async function registerOpen(): Promise { + await registry().handleRegister('slack', { channels: [{ id: X, type: 'chat', label: '#room (slack)', direction: 'bidirectional' }] }); + await registry().handleChannelToolCall('channel_open', { serverId: 'slack', channelId: X }, { kind: 'module' }); + } + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'reply-router-')); + for (const id of ['discord', 'slack']) writeFileSync(paths(id).command, ''); + const original = console.error; + console.error = () => {}; + restoreErrors = () => { console.error = original; }; + const membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [ + { id: 'discord', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('discord').status, COMMAND_PATH: paths('discord').command } }, + { id: 'slack', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: paths('slack').status, COMMAND_PATH: paths('slack').command, + CHANNELS: JSON.stringify([{ id: 'slack:T1:C1', label: '#lobby (acme)' }]) } }, + ], + modules: [], + conversations: { templateAgent: 'scout', bind: { channel: 'always' } }, + }); + await framework.start(); + await waitFor(() => registry().listChannelsRaw().length >= 3, 'both servers registered'); + }); + + afterEach(async () => { + await framework?.stop(); + framework = undefined; + restoreErrors(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("a server that registers a bound channel's id too has its message answered in a fork of its own, and each fork takes only its own server's gate route", async () => { + await registerOpen(); + command('discord', { op: 'incoming', channelId: X, messageId: 'd-1', mode: 'addressed', text: 'from discord' }); + await waitFor(() => holders('from discord').length > 0, "discord's message reached a fork"); + command('slack', { op: 'incoming', channelId: X, messageId: 's-1', mode: 'addressed', text: 'from slack' }); + await waitFor(() => holders('from slack').length > 0, "slack's message reached a fork"); + await framework!.runUntilIdle(); + const [discordFork] = holders('from discord'); + assert.deepEqual(holders('from discord'), [discordFork]); + assert.ok(!holders('from slack').includes(discordFork!), "slack's message is not in discord's fork"); + assert.equal(holders('from slack').length, 1); + const [slackFork] = holders('from slack'); + const take = (agent: string, server: string) => + (framework as unknown as { mayTakeGateRoute(a: string, r: ChannelRef): boolean }).mayTakeGateRoute(agent, { serverId: server, channelId: X }); + assert.equal(take(discordFork!, 'discord'), true, "discord's fork takes discord's route"); + assert.equal(take(discordFork!, 'slack'), false, "discord's fork doesn't take slack's"); + assert.equal(take(slackFork!, 'slack'), true, "slack's fork takes slack's route"); + assert.equal(take(slackFork!, 'discord'), false, "slack's fork doesn't take discord's"); + }); + + it("each server's next message goes on to its own fork", async () => { + await registerOpen(); + command('discord', { op: 'incoming', channelId: X, messageId: 'd-1', mode: 'addressed', text: 'discord first' }); + await waitFor(() => holders('discord first').length > 0, "discord's first message reached a fork"); + command('slack', { op: 'incoming', channelId: X, messageId: 's-1', mode: 'addressed', text: 'slack first' }); + await waitFor(() => holders('slack first').length > 0, "slack's first message reached a fork"); + command('discord', { op: 'incoming', channelId: X, messageId: 'd-2', mode: 'addressed', text: 'discord second' }); + command('slack', { op: 'incoming', channelId: X, messageId: 's-2', mode: 'addressed', text: 'slack second' }); + await waitFor(() => holders('discord second').length > 0 && holders('slack second').length > 0, 'both second messages reached a fork'); + await framework!.runUntilIdle(); + assert.deepEqual(holders('discord second'), holders('discord first')); + assert.deepEqual(holders('slack second'), holders('slack first')); + assert.notDeepEqual(holders('slack first'), holders('discord first')); + }); +}); + +describe("a gate wake's route, with two forks on one channel id", () => { + type Gate = { conversationRouter: ConversationRouter; conversationAgentHomes: Map; mayTakeGateRoute(a: string, r: ChannelRef): boolean }; + const d = { serverId: 'discord', channelId: 'room' }; + const s = { serverId: 'slack', channelId: 'room' }; + + it("is taken only by the fork whose home is the route's server and channel", () => { + const router = new ConversationRouter({ templateAgent: 'scout' }); + router.bind(d, 'fork-d', 1); + router.bind(s, 'fork-s', 2); + const framework = Object.create(AgentFramework.prototype) as Gate; + framework.conversationRouter = router; + framework.conversationAgentHomes = new Map([['fork-d', d], ['fork-s', s]]); + assert.equal(framework.mayTakeGateRoute('fork-s', s), true); + assert.equal(framework.mayTakeGateRoute('fork-d', s), false, "discord's fork doesn't take slack's route"); + assert.equal(framework.mayTakeGateRoute('fork-d', d), true); + assert.equal(framework.mayTakeGateRoute('fork-s', d), false); + assert.equal(framework.mayTakeGateRoute('scout', s), false, 'nor does an agent that is no fork'); + }); + + it("with one fork bound, it takes its own server's route and not another server's on that id", (t) => { + const router = new ConversationRouter({ templateAgent: 'scout' }); + router.bind(d, 'fork-d', 1); + const framework = Object.create(AgentFramework.prototype) as Gate; + framework.conversationRouter = router; + framework.conversationAgentHomes = new Map([['fork-d', d]]); + assert.equal(framework.mayTakeGateRoute('fork-d', d), true); + assert.equal(framework.mayTakeGateRoute('fork-d', s), false); + t.diagnostic(`the trunk takes slack's route on that id: ${framework.mayTakeGateRoute('scout', s)}`); + }); +}); + +describe("the router's bindings, by server and id", () => { + it("keeps two servers' channels with one id apart", () => { + const router = new ConversationRouter({ templateAgent: 'scout' }); + router.bind({ serverId: 'discord', channelId: 'c' }, 'fork-d', 1); + router.bind({ serverId: 'slack', channelId: 'c' }, 'fork-s', 2); + assert.equal(router.getBinding({ serverId: 'discord', channelId: 'c' })?.agentName, 'fork-d'); + assert.equal(router.getBinding({ serverId: 'slack', channelId: 'c' })?.agentName, 'fork-s'); + router.unbind({ serverId: 'slack', channelId: 'c' }); + assert.equal(router.getBinding({ serverId: 'discord', channelId: 'c' })?.agentName, 'fork-d', "unbinding one server's channel leaves the other's"); + assert.equal(router.getBinding({ serverId: 'slack', channelId: 'c' }), undefined); + }); +}); + +describe("a name the turn's own channel answers to, in the registry", () => { + async function registry(): Promise { + const r = new ChannelRegistry({ getServer: () => null } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}); + for (const id of ['a', 'b']) { + await r.handleRegister(id, { channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }] }); + } + return r; + } + const a = { serverId: 'a', channelId: 'general' }; + const b = { serverId: 'b', channelId: 'general' }; + + it("means the turn's channel, on its server, though another server registers the id", async () => { + const r = await registry(); + assert.ok('error' in r.resolveProseTarget('general', { forSpeech: true }), 'without the turn, the shared id is refused'); + assert.deepEqual(r.resolveProseTarget('general', { forSpeech: true, own: [b] }), { channelId: 'general', serverId: 'b', label: '#general' }); + assert.deepEqual(r.resolveProseTarget('a:general', { forSpeech: true, own: [b] }), { channelId: 'general', serverId: 'a', label: '#general' }, + 'a qualified name still picks its own channel'); + }); + + it("is refused once that server no longer registers it, with no other channel offered", async () => { + const r = await registry(); + await r.handleChanged('b', { removed: ['general'] }); + assert.deepEqual(r.resolveProseTarget('general', { forSpeech: true, own: [b] }), + { error: '"general" is this turn\'s channel on server "b", which no longer registers it, and nothing can be sent there until that server registers it again' }); + assert.deepEqual(r.resolveProseTarget('general', { forSpeech: true }), { channelId: 'general', serverId: 'a', label: '#general' }, + 'outside the turn the id is a\'s alone'); + }); + + it("means neither where the turn's channels with that id are on two servers", async () => { + const r = await registry(); + assert.deepEqual(r.resolveProseTarget('general', { forSpeech: true, own: [a, b, a] }), + { error: '"general" is the id of this turn\'s channels on more than one server', candidates: ['a:general', 'b:general'] }); + }); + + it("names each of the turn's channels it means neither of by a name that reaches it, and one its server no longer registers as such", async () => { + const r = await registry(); + await r.handleChanged('a', { removed: ['general'] }); + // b's general is now the id's only holder, but the turn's rule refuses the bare id, so its qualified name is offered. + assert.deepEqual(r.resolveProseTarget('general', { forSpeech: true, own: [a, b] }), + { error: '"general" is the id of this turn\'s channels on more than one server', + candidates: ['"general" on server "a" (its server no longer registers it)', 'b:general'] }); + assert.deepEqual(r.resolveProseTarget('b:general', { forSpeech: true, own: [a, b] }), { channelId: 'general', serverId: 'b', label: '#general' }, + 'the offered name reaches it'); + const published = await r.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'x' }, { kind: 'agent', agentName: 'scout', own: [a, b] } as never); + assert.equal(published.error, '"general" is the id of this turn\'s channels on more than one server ("general" on server "a" (its server ' + + 'no longer registers it), "b:general"), so nothing was posted. Call again with channelId "b:general".'); + }); + + it("names and offers the turn's channels by their qualified names though their id ends in a parenthesis", async () => { + const r = new ChannelRegistry({ getServer: () => null } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}); + for (const id of ['a', 'b']) { + await r.handleRegister(id, { channels: [{ id: 'room (old)', type: 'chat', label: '#room', direction: 'bidirectional' }] }); + } + const own = [{ serverId: 'a', channelId: 'room (old)' }, { serverId: 'b', channelId: 'room (old)' }]; + const published = await r.handleChannelToolCall('channel_publish', { channelId: 'room (old)', content: 'x' }, { kind: 'agent', agentName: 'scout', own } as never); + assert.equal(published.error, '"room (old)" is the id of this turn\'s channels on more than one server ("a:room (old)", "b:room (old)"), ' + + 'so nothing was posted. Call again with channelId set to one of them.'); + }); +}); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 79e4a98ee..e1d918054 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -10,7 +10,7 @@ import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { ProcessEvent, Module, ModuleContext, ToolDefinition, ToolCall, ToolResult, EventResponse } from '../src/index.js'; import type { ContentBlock, NormalizedResponse } from '@animalabs/membrane'; -import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { destinationKey, type ChannelRegistry } from '../src/mcpl/channel-registry.js'; import { HeldStream, MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; import { UNREACHED_WATCHDOG, silenceRun } from './helpers/run-watchdog.js'; @@ -75,7 +75,7 @@ class SenderModule implements Module { } async handleToolCall(call: ToolCall): Promise { if (call.name.endsWith('post')) { - await internals(this.framework!).channelRegistry.routeSpeech(this.speaker, 'mid-turn reply', 'missing-channel'); + await internals(this.framework!).channelRegistry.routeSpeech(this.speaker, 'mid-turn reply', { serverId: 'srv', channelId: 'missing-channel' }); } if (this.quiesceOnCall) internals(this.framework!).quiesced = true; if (this.endTurnOnPost && call.name.endsWith('post')) return { success: true, data: { ok: true }, endTurn: true }; @@ -128,10 +128,10 @@ describe('notices about an agent\'s own action', () => { }); /** An incoming DM on `channel`, which no channel the registry knows. */ - function dm(messageId: string, text: string, channel = 'missing-channel'): ProcessEvent { + function dm(messageId: string, text: string, channel = 'missing-channel', server = 'srv'): ProcessEvent { return { type: 'mcpl:channel-incoming', - serverId: 'srv', + serverId: server, channelId: channel, messageId, author: { id: 'user-1', name: 'User' }, @@ -143,11 +143,11 @@ describe('notices about an agent\'s own action', () => { } /** A real conversation fork: an incoming DM binds one, and its reply fails (no such channel). */ - async function spawnFork(reply = 'Reply from the fork.', channel = 'missing-channel') { + async function spawnFork(reply = 'Reply from the fork.', channel = 'missing-channel', server = 'srv') { membrane.pushResponse(createMockResponse([{ type: 'text', text: reply }])); - framework.pushEvent(dm(channel === 'missing-channel' ? 'incoming-1' : `incoming-${channel}`, 'Hello fork.', channel)); + framework.pushEvent(dm(channel === 'missing-channel' ? 'incoming-1' : `incoming-${channel}`, 'Hello fork.', channel, server)); await framework.runUntilIdle(); - const binding = framework.getConversationRouter()!.getBinding(channel); + const binding = framework.getConversationRouter()!.getBinding({ serverId: server, channelId: channel }); assert.ok(binding, 'the incoming DM must spawn a real conversation fork'); return framework.getAgent(binding.agentName)!; } @@ -180,7 +180,7 @@ describe('notices about an agent\'s own action', () => { it('addresses a secondary resident\'s notice to it, in the slot every resident reads', async (t) => { const primary = t.mock.method(framework.getAgent('primary')!.getContextManager(), 'addMessage'); const secondary = t.mock.method(framework.getAgent('secondary')!.getContextManager(), 'addMessage'); - const result = await internals(framework).channelRegistry.routeSpeech('secondary', 'hello', 'missing-channel'); + const result = await internals(framework).channelRegistry.routeSpeech('secondary', 'hello', { serverId: 'srv', channelId: 'missing-channel' }); assert.equal(result, null); assert.equal(primary.mock.callCount(), 0); @@ -191,7 +191,8 @@ describe('notices about an agent\'s own action', () => { assert.equal(metadata?.system, true); assert.equal(metadata?.kind, 'discord-send-failed'); assert.equal(metadata?.channelId, 'missing-channel'); - assert.equal(metadata?.reason, 'no channel "missing-channel" is registered here'); + // The turn's locus names its server, so the reason does too. + assert.equal(metadata?.reason, 'the "srv" server has no channel "missing-channel" registered here'); assert.equal(metadata?.textLen, 5); t.mock.restoreAll(); const { messages } = await framework.getAgent('secondary')!.getContextManager().compile(); @@ -278,13 +279,13 @@ describe('notices about an agent\'s own action', () => { const primary = t.mock.method(framework.getAgent('primary')!.getContextManager(), 'addMessage'); const secondary = t.mock.method(framework.getAgent('secondary')!.getContextManager(), 'addMessage'); - await internals(framework).channelRegistry.routeSpeech('released-conversation', 'hello', 'missing-channel'); + await internals(framework).channelRegistry.routeSpeech('released-conversation', 'hello', { serverId: 'srv', channelId: 'missing-channel' }); assert.equal(secondary.mock.callCount(), 0); assert.equal(primary.mock.callCount(), 1); const [, content, metadata] = primary.mock.calls[0]!.arguments; assert.match(JSON.stringify(content), - /\[discord-send-failed\] A reply by released-conversation \(5 chars\) could not be delivered to missing-channel \(no channel \\"missing-channel\\" is registered here\)\. Nothing was posted\. released-conversation has ended\./); + /\[discord-send-failed\] A reply by released-conversation \(5 chars\) could not be delivered to missing-channel \(the \\"srv\\" server has no channel \\"missing-channel\\" registered here\)\. Nothing was posted\. released-conversation has ended\./); assert.equal(metadata?.speaker, 'released-conversation'); assert.equal(metadata?.textLen, 5); assert.equal(internals(framework).pendingRequests.length, 0, 'context only: the primary is not woken'); @@ -364,7 +365,7 @@ describe('notices about an agent\'s own action', () => { // After the run's release, its speech is routed to a channel given // directly, as a channel the run opened or named would be; the channel // isn't registered, so the delivery fails. - await internals(framework).channelRegistry.routeSpeech('worker', 'Worker speech for a channel that is not registered.', 'missing-channel'); + await internals(framework).channelRegistry.routeSpeech('worker', 'Worker speech for a channel that is not registered.', { serverId: 'srv', channelId: 'missing-channel' }); const { messages } = await framework.getAgent('primary')!.getContextManager().compile(); const named = messages.filter((m) => JSON.stringify(m.content).includes(SEND_FAILED)); @@ -385,7 +386,7 @@ describe('notices about an agent\'s own action', () => { await runEphemeral('locus', 'Worker speech.'); assert.equal(framework.getAgent('worker'), null, 'the run is released'); - await internals(framework).channelRegistry.routeSpeech('worker', 'Worker speech for a channel that is not registered.', 'missing-channel'); + await internals(framework).channelRegistry.routeSpeech('worker', 'Worker speech for a channel that is not registered.', { serverId: 'srv', channelId: 'missing-channel' }); assert.ok(errors.some((e) => e.includes('[route-failure] failed to tell the primary that worker\'s reply to missing-channel failed'))); }); @@ -410,7 +411,7 @@ describe('notices about an agent\'s own action', () => { await bare.runUntilIdle(); // After the run's release, its speech is routed to a channel given // directly, as a channel of its own would be, and the delivery fails. - await internals(bare).channelRegistry.routeSpeech('worker', 'Worker speech with nobody to tell.', 'missing-channel'); + await internals(bare).channelRegistry.routeSpeech('worker', 'Worker speech with nobody to tell.', { serverId: 'srv', channelId: 'missing-channel' }); const archive = await ContextManager.open({ store: bare.getStore(), namespace: 'subagent/worker', isolate: true, strategy: new PassthroughStrategy(), @@ -621,7 +622,12 @@ describe('notices about an agent\'s own action', () => { const told = messages.filter((m) => JSON.stringify(m.content).includes(SEND_FAILED)); assert.equal(told.length, 1); assert.match(JSON.stringify(told[0]!.content), - new RegExp(`A reply by ${fork.name} \\(${reply.length} chars\\) could not be delivered to missing-channel \\(no channel \\\\"missing-channel\\\\" is registered here\\)\\. Nothing was posted\\. ${fork.name} has ended\\.`)); + new RegExp(`A reply by ${fork.name} \\(${reply.length} chars\\) could not be delivered to missing-channel \\(the \\\\"srv\\\\" server has no channel \\\\"missing-channel\\\\" registered here\\)\\. Nothing was posted\\. ${fork.name} has ended\\.`)); + // The marker passed on names the channel's server, as the fork's did. + const markers = framework.getAgent('primary')!.getContextManager().queryMessages({}).messages + .map((m) => m.metadata as { kind?: string; channelId?: string; serverId?: string } | undefined) + .filter((m) => m?.kind === 'discord-send-failed'); + assert.deepEqual(markers.map((m) => [m?.channelId, m?.serverId]), [['missing-channel', 'srv']]); }); it('passes on only what a fork never read: a notice its later turn compiled stays its own', async () => { @@ -672,7 +678,7 @@ describe('notices about an agent\'s own action', () => { triggerInference: false, } as unknown as ProcessEvent); await framework.runUntilIdle(); - const binding = framework.getConversationRouter()!.getBinding('missing-channel'); + const binding = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' }); assert.ok(binding, 'the DM spawns a real conversation fork'); assert.equal(membrane.calls.length, 0, 'no turn read the message'); const row = framework.getAgent(binding.agentName)!.getContextManager().getAllMessages() @@ -699,7 +705,7 @@ describe('notices about an agent\'s own action', () => { /** The registry learns a label for `channel` after the fork's reply to it failed. */ function learnLabel(channel: string, label: string) { (internals(framework).channelRegistry as unknown as { channels: Map }).channels - .set(`srv:${channel}`, { serverId: 'srv', descriptor: { id: channel, type: 'discord', label }, open: true }); + .set(destinationKey({ serverId: 'srv', channelId: channel }), { serverId: 'srv', descriptor: { id: channel, type: 'discord', label }, open: true }); } async function primaryNotices() { @@ -1044,13 +1050,13 @@ describe('notices about an agent\'s own action', () => { // After stop() the queue refuses new events, so only one already queued can reach the router. framework.pushEvent(dm('incoming-2', 'LEFT QUEUED')); await framework.stop(); - assert.equal(framework.getConversationRouter()!.getBinding('missing-channel'), undefined, 'the channel unbinds with its fork'); + assert.equal(framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' }), undefined, 'the channel unbinds with its fork'); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Reply from the next fork.' }])); framework.start(); const deadline = Date.now() + 10_000; let next: string | undefined; - while (Date.now() < deadline && !(next = framework.getConversationRouter()!.getBinding('missing-channel')?.agentName)) { + while (Date.now() < deadline && !(next = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' })?.agentName)) { await new Promise((resolve) => setTimeout(resolve, 20)); } assert.ok(next && next !== fork.name, `a new generation binds the channel (bound: ${next})`); @@ -1087,7 +1093,7 @@ describe('notices about an agent\'s own action', () => { framework.pushEvent(dm('incoming-1', 'Hello fork.')); await framework.runUntilIdle(); assert.ok(ended, 'the fork was created and then ended before its bind'); - assert.equal(framework.getConversationRouter()!.getBinding('missing-channel'), undefined, 'no binding names the ended fork'); + assert.equal(framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' }), undefined, 'no binding names the ended fork'); assert.equal(failed.length, 1, 'the spawn fails, as a failed spawn does'); assert.match(failed[0]!, /ended before its channel was bound/); @@ -1095,7 +1101,7 @@ describe('notices about an agent\'s own action', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Reply from the next fork.' }])); framework.pushEvent(dm('incoming-2', 'Hello again.')); await framework.runUntilIdle(); - const next = framework.getConversationRouter()!.getBinding('missing-channel')?.agentName; + const next = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' })?.agentName; assert.ok(next && next !== ended, `the next generation binds the channel (bound: ${next}; failed: ${failed.join(' | ')})`); }); @@ -1187,7 +1193,9 @@ describe('notices about an agent\'s own action', () => { }); it("keeps the server the registry names for an open a delivery forced, as the framework's callback writes the notice's inputs", async () => { - const fork = await spawnFork(); + // The fork's home is on a server other than the fixture's own, so a + // notice built with any server but the callback's names the wrong one. + const fork = await spawnFork('Reply from the fork.', 'missing-channel', 'other-server'); const registry = internals(framework).channelRegistry as unknown as { onChannelAutoOpened(info: { conversationId?: string; serverId: string; source: string; channels: Array<{ channelId: string; label?: string }> }): void; }; @@ -1229,7 +1237,7 @@ describe('notices about an agent\'s own action', () => { (framework as unknown as { pushEvent: unknown }).pushEvent = push; } assert.equal(results[0]?.success, false); - assert.match(results[0]?.error ?? '', /closing c-1 is not allowed/); + assert.match(results[0]?.error ?? '', /closing "c-1" is not allowed/); }); it('tells the primary, by name, of a channel notice a fork could act on and never read, when the fork is released', async () => { @@ -1288,7 +1296,7 @@ describe('notices about an agent\'s own action', () => { const state = internals(framework); const channels = (state.channelRegistry as unknown as { channels: Map }).channels; // The channel the ended run's send opened, on a server that lets it close. - channels.set('srv:opened-1', { serverId: 'srv', descriptor: { id: 'opened-1', type: 'srv', label: '#opened' }, open: true } as never); + channels.set(destinationKey({ serverId: 'srv', channelId: 'opened-1' }), { serverId: 'srv', descriptor: { id: 'opened-1', type: 'srv', label: '#opened' }, open: true } as never); const closed: unknown[] = []; const server = { grant: new CapabilityGrant(new Set(['channels.publish', 'channels.lifecycle']), []), @@ -1309,9 +1317,9 @@ describe('notices about an agent\'s own action', () => { // The remedy it names is the primary's to use. (framework as unknown as { dispatchChannelToolCall(agent: string, call: { id: string; name: string; input: Record }): void }) .dispatchChannelToolCall('primary', { id: 'close-1', name: 'channel_close', input: { channelId: 'opened-1' } }); - for (let i = 0; i < 50 && channels.get('srv:opened-1')!.open; i++) await new Promise((resolve) => setImmediate(resolve)); + for (let i = 0; i < 50 && channels.get(destinationKey({ serverId: 'srv', channelId: 'opened-1' }))!.open; i++) await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(closed, [{ channelId: 'opened-1' }]); - assert.equal(channels.get('srv:opened-1')!.open, false, 'the channel the send opened is closed'); + assert.equal(channels.get(destinationKey({ serverId: 'srv', channelId: 'opened-1' }))!.open, false, 'the channel the send opened is closed'); }); it('tells no one, logged, of a channel an ended agent\'s send opened when there is no primary', async (t) => { @@ -1426,12 +1434,12 @@ describe('notices about an agent\'s own action', () => { // Selected for opening, and open since. decide('c-9', 'open', 'server-bootstrap'); (state.channelRegistry as unknown as { channels: Map }).channels - .set('srv:c-9', { serverId: 'srv', descriptor: { id: 'c-9', type: 'srv', label: '#nine' }, open: true }); + .set(destinationKey({ serverId: 'srv', channelId: 'c-9' }), { serverId: 'srv', descriptor: { id: 'c-9', type: 'srv', label: '#nine' }, open: true }); // Selected for opening on its own server, where it isn't open, while // another server's channel with the same id is. decide('c-22', 'open', 'server-bootstrap'); (state.channelRegistry as unknown as { channels: Map }).channels - .set('other:c-22', { serverId: 'other', descriptor: { id: 'c-22', type: 'other', label: '#twenty-two' }, open: true }); + .set(destinationKey({ serverId: 'other', channelId: 'c-22' }), { serverId: 'other', descriptor: { id: 'c-22', type: 'other', label: '#twenty-two' }, open: true }); // Selected for opening, and closed since. decide('c-10', 'open', 'server-bootstrap'); decide('c-10', 'closed', 'agent-tool'); @@ -1481,7 +1489,7 @@ describe('notices about an agent\'s own action', () => { "If you don't want it, use channel_close: neither 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.", - "[channels] Not open: #twenty-two (c-22). A reply from conversation-gone went out there, but opening the channel failed; conversation-gone has ended. " + + "[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.", ]); assert.deepEqual(told.map((m) => (m.metadata as { deferredWriteId?: unknown }).deferredWriteId), ['w-7', 'w-13', 'w-14', 'w-22']); @@ -1722,7 +1730,7 @@ describe('notices about an agent\'s own action', () => { }, }; t.mock.method(state.mcplServerRegistry, 'getServer', () => publisher as never); - (state.channelRegistry as unknown as { channels: Map }).channels.set('srv:' + channelId, { + (state.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'srv', channelId }), { serverId: 'srv', descriptor: { id: channelId, type: 'srv', label: 'closure test' }, open: true, }); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Initial reply.' }])); @@ -1735,7 +1743,7 @@ describe('notices about an agent\'s own action', () => { triggerInference: true, } as unknown as ProcessEvent); await framework.runUntilIdle(); - const binding = framework.getConversationRouter()!.getBinding(channelId)!; + const binding = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId })!; assert.ok(binding); const fork = framework.getAgent(binding.agentName)!; diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index 3268a397d..dcbe0f42d 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -122,7 +122,7 @@ function makeChannelRegistry(framework: AgentFramework) { } return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true }; }, - resolveLocus: () => 'chan-test', + resolveLocus: () => ({ serverId: 'stub', channelId: 'chan-test' }), routeSpeech: async (_agent: string, text: string) => { routed.push(text); }, diff --git a/test/shared-channel-id.test.ts b/test/shared-channel-id.test.ts new file mode 100644 index 000000000..91ded35a2 --- /dev/null +++ b/test/shared-channel-id.test.ts @@ -0,0 +1,1833 @@ +/** + * Two MCPL servers that register one channel id. MCPL requires a channel id + * to be unique only within one server's connection (§14.2), so servers `a` + * and `b` can each register `general`. Every path that acts on a channel then + * names it with its server (ChannelRef), and a name given as a string, which + * two servers' channels answer to, is refused with both as candidates, never + * taken to be whichever registered first. The cases in the `orders` loops + * run in both registration orders, since a first-match lookup answers + * differently in each; the rest, whose answer doesn't turn on which server + * registered first, run once. + */ + +import { afterEach, test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { ChannelRef, InferenceRequest, ProcessEvent } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { isProseTargetToken } from '../src/mcpl/prose-grammar.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; +import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js'; +import { TuneOutCoordinator } from '../src/tune-out/coordinator.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'; + +type Call = { server: string; what: string; channelId?: string; text?: string; metadata?: unknown }; + +/** Each test's registries, whose typing indicators are stopped after it, so + * one that fails mid-way leaves no interval holding the process open. */ +const registries: ChannelRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.stopAll(); +}); + +/** A registry whose servers record what each is sent, with the channels + * `[serverId, channelId, label]` registered open in the order given. */ +function fixture(channels: Array<[serverId: string, channelId: string, label?: string]>) { + const calls: Call[] = []; + const failures: Array<{ channelId: string | null; serverId?: string; reason: string }> = []; + const traces: Array<{ type: string; [key: string]: unknown }> = []; + const servers = new Map(); + const server = (id: string) => ({ + id, + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (p: { channelId?: string; content?: Array<{ text: string }> }) => { + calls.push({ server: id, what: 'publish', channelId: p.channelId, text: p.content?.[0]?.text }); + return { delivered: true, messageId: `${id}-m${calls.length}` }; + }, + sendChannelsOpen: async (p: { channelId?: string }) => { calls.push({ server: id, what: 'open', channelId: p.channelId }); return {}; }, + sendChannelsClose: async (p: { channelId?: string }) => { calls.push({ server: id, what: 'close', channelId: p.channelId }); return {}; }, + sendChannelsOutgoingChunk: (p: { channelId?: string }) => { calls.push({ server: id, what: 'chunk', channelId: p.channelId }); }, + sendChannelsOutgoingComplete: (p: { channelId?: string }) => { calls.push({ server: id, what: 'complete', channelId: p.channelId }); }, + }); + const getServer = (id: string) => servers.get(id) ?? servers.set(id, server(id)).get(id); + const registry = new ChannelRegistry( + { getServer } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + (e) => { traces.push(e); }, + { + onRouteFailure: (info) => { failures.push(info); }, + sendTypingFn: (serverId, channelId, metadata, op) => { + calls.push({ server: serverId, what: op === 'stop' ? 'typing-stop' : 'typing', channelId, ...(metadata ? { metadata } : {}) }); + }, + }, + ); + registries.push(registry); + const map = (registry as unknown as { channels: Map }).channels; + for (const [serverId, channelId, label] of channels) { + map.set(destinationKey({ serverId, channelId }), { + serverId, descriptor: { id: channelId, type: 'chat', label: label ?? channelId }, open: true, + }); + } + const take = () => calls.splice(0); + return { registry, calls, take, failures, traces }; +} + +const a: ChannelRef = { serverId: 'a', channelId: 'general' }; +const b: ChannelRef = { serverId: 'b', channelId: 'general' }; +const orders: Array> = [['a', 'b'], ['b', 'a']]; +const both = (order: Array<'a' | 'b'>) => order.map((s): [string, string, string] => [s, 'general', `#general (${s})`]); + +for (const order of orders) { + const registered = `registered ${order.join(' then ')}`; + + test(`speech to a turn's locus goes to its server, with its stream there (${registered})`, async () => { + const f = fixture(both(order)); + const routed = await f.registry.routeSpeech('agent', 'reply', b); + assert.deepEqual(routed, { delivered: true, serverId: 'b', channelId: 'general', messageId: 'b-m1' }); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['b publish']); + f.registry.sendOutgoingChunk(b, 'agent', 'inf', 0, 'reply'); + f.registry.sendOutgoingComplete(b, 'agent', 'inf', 'reply'); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['b chunk', 'b complete']); + assert.deepEqual(f.failures, []); + }); + + test(`typing by ref types in that server's channel, and a host's bare id types in each server's (${registered})`, () => { + const f = fixture(both(order)); + f.registry.startTyping(b); + f.registry.stopTyping(b); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['b typing', 'b typing-stop']); + + // connectome-host's activity module keeps bare ids: typing posts + // nothing, so a shared one shows in both rather than in neither. + f.registry.startTyping('general'); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`).sort(), ['a typing', 'b typing']); + f.registry.stopTyping('general'); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`).sort(), ['a typing-stop', 'b typing-stop']); + // A qualified name picks one. + f.registry.startTyping('a:general'); + f.registry.stopTyping('a:general'); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['a typing', 'a typing-stop']); + }); + + test(`each server's channel with the shared id keeps its own typing metadata (${registered})`, () => { + const f = fixture(both(order)); + f.registry.startTyping(a, { topic: 'a-topic' }); + f.registry.startTyping(b, { topic: 'b-topic' }); + f.registry.stopTyping(a); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what} ${JSON.stringify(c.metadata)}`), [ + 'a typing {"topic":"a-topic"}', 'b typing {"topic":"b-topic"}', 'a typing-stop {"topic":"a-topic"}', + ]); + f.registry.stopTyping(b); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what} ${JSON.stringify(c.metadata)}`), ['b typing-stop {"topic":"b-topic"}']); + }); + + test(`removing a server stops its channel's typing and keeps the default's ref, whichever server's it was, and drops the default's message only when the default is that server's (${registered})`, () => { + const f = fixture(both(order)); + const internal = f.registry as unknown as { + defaultPublishChannel: ChannelRef | null; defaultPublishMessageId: string | null; defaultPublishThreadId: string | undefined; + channels: Map; + }; + f.registry.startTyping(a); + f.registry.startTyping(b); + internal.defaultPublishChannel = b; + internal.defaultPublishMessageId = 'b-msg'; + internal.defaultPublishThreadId = 'b-thread'; + f.take(); + f.registry.removeServer('a'); + assert.deepEqual(f.registry.getDefaultPublishChannel(), b, "b's channel stays the default"); + assert.deepEqual(f.registry.incomingFor(), { channel: b, messageId: 'b-msg', threadId: 'b-thread' }, "another server's removal keeps b's message"); + f.registry.stopTyping(b); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['b typing-stop'], "b's typing was still running"); + // a's typing stopped with its server: registered again, it starts afresh. + internal.channels.set(destinationKey(a), { serverId: 'a', descriptor: { id: 'general', type: 'chat', label: '#general' }, open: true }); + f.registry.startTyping(a); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['a typing']); + f.registry.removeServer('b'); + assert.deepEqual(f.registry.getDefaultPublishChannel(), b, "b's channel stays the default's ref, so a call that would default to it is refused as the default gone"); + assert.equal(f.registry.incomingFor(), undefined, "b's message went with its server, so none is given as the one replied to"); + assert.deepEqual([internal.defaultPublishMessageId, internal.defaultPublishThreadId], [null, undefined]); + }); + + test(`channel_close stops typing in the channel it closes, and the other server's channel with its id goes on typing (${registered})`, async () => { + const f = fixture(both(order)); + f.registry.startTyping(a); + f.registry.startTyping(b); + f.take(); + await f.registry.handleChannelToolCall('channel_close', { channelId: 'b:general' }); + assert.deepEqual(f.take().filter((c) => c.what.startsWith('typing')).map((c) => `${c.server} ${c.what}`), ['b typing-stop']); + f.registry.stopTyping(a); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what}`), ['a typing-stop'], "a's typing was still running"); + }); + + test(`channel_publish refuses a shared id with its candidates, and takes the server-qualified name (${registered})`, async () => { + const f = fixture(both(order)); + const refused = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'hi' }); + assert.equal(refused.success, false); + assert.equal( + refused.error, + `Channel "general" is on more than one server (${order.map((s) => `"${s}:general"`).join(', ')}), so nothing was posted. ` + + 'Call again with channelId set to one of them.', + ); + // connectome-host's notices take "not found" as an absent server and park the notice for good. + assert.doesNotMatch(refused.error ?? '', /not found/i); + assert.deepEqual(f.take(), [], 'nothing was published'); + + const sent = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', content: 'hi' }); + assert.equal(sent.success, true); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what} ${c.channelId}`), ['b publish general']); + }); + + test(`publishForAgent refuses a shared id, and takes a qualified name or a ref (${registered})`, async () => { + const f = fixture(both(order)); + const refused = await f.registry.publishForAgent('general', 'from the subconscious', 'Subconscious'); + assert.equal(refused.success, false); + assert.match(refused.error ?? '', /^Channel "general" is on more than one server/); + // A host reads why without the words: a name several channels answer to, + // which a ref settles, as a qualified name does here. + assert.equal(refused.reason, 'shared-id'); + // A ref whose server doesn't register that id names no registered channel. + const nowhere = await f.registry.publishForAgent({ serverId: 'z', channelId: 'general' }, 'x', 'Subconscious'); + assert.equal(nowhere.reason, 'channel-not-registered'); + // A ref names its exact pair, an empty serverId included, as publish's + // destination does, though one server alone registers the id. + const solo = fixture([['a', 'solo']]); + const unnamed = await solo.registry.publishForAgent({ serverId: '', channelId: 'solo' }, 'x', 'Subconscious'); + assert.equal(unnamed.reason, 'channel-not-registered'); + assert.match(unnamed.error ?? '', /^No channel "solo" is on server "", so nothing was posted\. /); + assert.deepEqual(solo.take(), []); + assert.deepEqual(f.take(), []); + + assert.equal((await f.registry.publishForAgent('a:general', 'one', 'Subconscious')).success, true); + assert.equal((await f.registry.publishForAgent(b, 'two', 'Subconscious')).success, true); + assert.deepEqual(f.take().map((c) => `${c.server} ${c.what} ${c.text}`), ['a publish one', 'b publish two']); + const routed = f.traces.filter((t) => t.type === 'mcpl:speech-routed').map((t) => `${t.serverId} ${t.channelId}`); + assert.deepEqual(routed, ['a general', 'b general'], 'each routed trace names its own server and the channel id'); + }); + + test(`channel_open and channel_close take the server-qualified name, as they take serverId (${registered})`, async () => { + const f = fixture(both(order)); + const opened = await f.registry.handleChannelToolCall('channel_open', { channelId: 'b:general' }); + assert.equal(opened.success, true); + assert.deepEqual(opened.data, { channelId: 'general', serverId: 'b', status: 'opened' }, + "the result names the channel's own id and its server"); + const refused = await f.registry.handleChannelToolCall('channel_close', { channelId: 'general' }); + assert.equal(refused.error, `Channel "general" is on more than one server (${order.map((s) => `"${s}:general"`).join(', ')}), ` + + `so nothing ran. Call again with channelId set to one of them, or with serverId set to ${order.map((s) => `"${s}"`).join(' or ')}.`); + for (const tool of ['channel_open', 'channel_decline']) { + const shared = await f.registry.handleChannelToolCall(tool, { channelId: 'general', messageId: 'm0' }); + assert.equal(shared.error, refused.error, `${tool} refuses the shared id as channel_close does`); + } + const closed = await f.registry.handleChannelToolCall('channel_close', { channelId: 'a:general' }); + assert.equal(closed.success, true, closed.error); + assert.deepEqual(closed.data, { channelId: 'general', serverId: 'a', status: 'closed' }); + const reopened = await f.registry.handleChannelToolCall('channel_open', { channelId: 'general', serverId: 'a' }); + assert.equal(reopened.success, true, reopened.error); + assert.deepEqual(f.take().filter((c) => c.what === 'open' || c.what === 'close').map((c) => `${c.server} ${c.what} ${c.channelId}`), + ['b open general', 'a close general', 'a open general'], 'each server is sent its own id'); + }); + + test(`channel_decline takes the server-qualified name, and records the decision for that server's channel (${registered})`, async () => { + const f = fixture(both(order)); + (f.registry as unknown as { channels: Map }).channels.get(destinationKey(b))!.open = false; + const declined = await f.registry.handleChannelToolCall('channel_decline', { channelId: 'b:general', messageId: 'm1' }); + assert.equal(declined.success, true, declined.error); + assert.deepEqual([(declined.data as { channelId?: string }).channelId, (declined.data as { serverId?: string }).serverId], ['general', 'b'], + 'the result names the channel and its server'); + assert.equal(f.registry.getDesiredState('b', 'general'), 'closed'); + 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/); + }); + + test(`a >> target names one server's channel by its qualified id, and a shared id or label lists them apart (${registered})`, () => { + const f = fixture(both(order)); + assert.deepEqual(f.registry.resolveProseTarget('b:general'), { channelId: 'general', serverId: 'b', label: '#general (b)' }); + assert.deepEqual(f.registry.resolveProseTarget('general'), { + error: 'channel id "general" is ambiguous across servers', + candidates: order.map((s) => `${s}:general`), + }); + // A label that picks one channel carries that channel's server. + assert.deepEqual(f.registry.resolveProseTarget('#general (a)'), { channelId: 'general', serverId: 'a', label: '#general (a)' }); + // Each channel's `>>` token resolves back to it, on its own server. + for (const ref of [a, b]) { + const token = f.registry.proseTargetFor(ref)!; + const back = f.registry.resolveProseTarget(token); + assert.ok('serverId' in back && back.serverId === ref.serverId && back.channelId === 'general', `${token} names ${ref.serverId}'s channel`); + } + }); + + test(`a shared label's candidates are named so they can be told apart (${registered})`, () => { + const f = fixture(order.map((s): [string, string, string] => [s, 'general', '#general'])); + assert.deepEqual(f.registry.resolveProseTarget('#general'), { + error: 'label "#general" matches several channels', + candidates: order.map((s) => `${s}:general`), + }); + assert.equal(f.registry.proseTargetFor(b), 'b:general'); + }); + + test(`each lookup by ref reads that server's own entry (${registered})`, () => { + const f = fixture(both(order)); + (f.registry as unknown as { channels: Map }).channels.get(destinationKey(a))!.open = false; + assert.equal(f.registry.getDescriptor(a)?.label, '#general (a)'); + assert.equal(f.registry.getDescriptor(b)?.label, '#general (b)'); + assert.equal(f.registry.isChannelOpen(a), false); + assert.equal(f.registry.isChannelOpen(b), true); + assert.deepEqual(f.registry.channelsNamed('general').map((r) => r.serverId).sort(), ['a', 'b']); + assert.equal(f.registry.nameShown(b), 'b:general'); + }); + + test(`a push's channel on one server is registered for it while another server holds the id (${registered})`, () => { + const [first, second] = order; + const f = fixture([[first, 'lazy']]); + f.registry.ensureChannelRegistered(second, 'lazy', 'Lazy'); + assert.equal(f.registry.getDescriptor({ serverId: second, channelId: 'lazy' })?.label, 'Lazy'); + assert.equal(f.registry.getDescriptor({ serverId: first, channelId: 'lazy' })?.label, 'lazy', "the other server's entry is untouched"); + // One server's push placeholder, labelled by its id, isn't relabelled by + // another server's push that names the id; its own later push names it. + const p = fixture([]); + p.registry.ensureChannelRegistered(first, 'pushed'); + p.registry.ensureChannelRegistered(second, 'pushed', 'Pushed'); + assert.equal(p.registry.getDescriptor({ serverId: first, channelId: 'pushed' })?.label, 'pushed', "the first server's placeholder keeps its label"); + assert.equal(p.registry.getDescriptor({ serverId: second, channelId: 'pushed' })?.label, 'Pushed'); + p.registry.ensureChannelRegistered(first, 'pushed', 'Pushed here'); + assert.equal(p.registry.getDescriptor({ serverId: first, channelId: 'pushed' })?.label, 'Pushed here', 'its own push names it'); + }); + + test(`each server is told only of its own channels in beforeInference's channel context (${registered})`, () => { + const f = fixture([...both(order), ['c', 'elsewhere']]); + f.registry.handleIncoming('b', { + messages: [{ channelId: 'general', messageId: 'in1', author: { id: 'u', name: 'U' }, timestamp: '2026-10-19T00:00:00.000Z', content: [{ type: 'text', text: 'hi' }] }], + }); + assert.deepEqual(f.registry.buildChannelContext('agent', 'b'), { + incoming: { channelId: 'general', messageId: 'in1', threadId: undefined }, + defaultOutgoing: { channelId: 'general' }, + candidates: ['general'], + }); + assert.deepEqual(f.registry.buildChannelContext('agent', 'a'), { candidates: ['general'] }, + "a hears of no message on b's general, nor of it as the default"); + assert.deepEqual(f.registry.buildChannelContext('agent', 'c'), { candidates: ['elsewhere'] }); + assert.equal(f.registry.buildChannelContext('agent', 'nobody'), undefined); + }); +} + +test("one person's DMs on two servers: a mention or a name lists both, told apart, rather than claiming none matches", async () => { + const f = fixture([]); + for (const serverId of ['a', 'b']) { + await f.registry.handleRegister(serverId, { channels: [{ + id: 'discord:dm:42', type: 'discord', label: 'DM: antra', direction: 'bidirectional', + metadata: { channelType: 'dm', recipientId: '42', recipientName: 'antra' }, + }] } as never); + } + const both = { error: '', candidates: ['a:discord:dm:42', 'b:discord:dm:42'] }; + assert.deepEqual(f.registry.resolveProseTarget('<@42>'), { ...both, error: '"<@42>" matches several DMs' }); + assert.deepEqual(f.registry.resolveProseTarget('@antra'), { ...both, error: '"@antra" matches several DMs' }); + assert.deepEqual(f.registry.resolveProseTarget('b:discord:dm:42'), + { channelId: 'discord:dm:42', serverId: 'b', label: 'DM: antra' }, 'a candidate names its DM back'); +}); + +test("a name offered for a >> line is one token, and not skip_reply, which the line reads as private speech", () => { + assert.equal(isProseTargetToken('a:general'), true); + assert.equal(isProseTargetToken('DM: antra'), false); + assert.equal(isProseTargetToken(''), false); + assert.equal(isProseTargetToken('skip_reply'), false); +}); + +test("channel_list names a channel whose id names another channel too by the name that picks it, and leaves the rest as they were", async () => { + const f = fixture([...both(['a', 'b']), ['a', 'elsewhere']]); + const listed = await f.registry.handleChannelToolCall('channel_list', {}); + const rows = listed.data as Array<{ id: string; name?: string; serverId: string }>; + assert.deepEqual(rows.map((r) => [r.serverId, r.id, r.name]), [ + ['a', 'general', 'a:general'], ['b', 'general', 'b:general'], ['a', 'elsewhere', undefined], + ]); + assert.ok(!('name' in rows[2]!), 'a row whose id picks it alone gains no field'); + // One server's channel whose id is another channel's qualified name: no + // other server shares the id, and it still gets the name that picks it. + const g = fixture([['s', 's:room'], ['s', 'room']]); + const one = (await g.registry.handleChannelToolCall('channel_list', {})).data as Array<{ id: string; name?: string }>; + assert.deepEqual(one.map((r) => [r.id, r.name]), [['s:room', 's:s:room'], ['room', undefined]]); + // The names come from one pass over the channels, not a lookup of every + // channel per row, so a listing stays linear in the channels. + const lookups = { n: 0 }; + const reg = f.registry as unknown as { entriesNamed(name: string): unknown[] }; + const entriesNamed = reg.entriesNamed.bind(f.registry); + reg.entriesNamed = (name: string) => { lookups.n++; return entriesNamed(name); }; + await f.registry.handleChannelToolCall('channel_list', {}); + assert.equal(lookups.n, 0, 'no per-row lookup'); +}); + +test("a fork's qualified-name refusal quotes its home's name as an id is quoted, escapes included", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const odd: ChannelRef = { serverId: 'b', channelId: 'q"x' }; + (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey(odd), { + serverId: 'b', descriptor: { id: 'q"x', type: 'chat', label: 'q"x' }, open: true, + }); + (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', odd); + t.after(() => { (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.delete('scout'); }); + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); + dispatch('scout', { id: 'q', name: 'channel_publish', input: { channelId: 'b:q"x', serverId: 'b', content: 'x' } }); + assert.equal(results.get('q')?.error, + '"b:q\\"x" names this conversation\'s channel by itself, so nothing was posted: call again with serverId left out.'); +}); + +test("what a channel tool says of a channel names it as the agent can name it back, before the send and after", async () => { + const f = fixture(both(['a', 'b'])); + // b's general declared inbound-only: channel_publish refuses it by the name that picks it. + await f.registry.handleChanged('b', { updated: [{ id: 'general', type: 'chat', label: 'general', direction: 'inbound' }] } as never); + const inbound = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', content: 'x' }); + assert.match(inbound.error ?? '', /^Channel b:general is inbound-only: server "b"/); + const target = f.registry.resolveProseTarget('b:general', { forSpeech: true }); + assert.match('error' in target ? target.error : '', /^b:general is inbound-only/); + // A default channel whose server is gone, while another server registers its id. + const internal = f.registry as unknown as { defaultPublishChannel: ChannelRef | null }; + internal.defaultPublishChannel = { serverId: 'c', channelId: 'general' }; + const gone = await f.registry.handleChannelToolCall('channel_publish', { content: 'x' }); + assert.equal(gone.error, 'The channel this would default to, "c:general", isn\'t registered now, so nothing was posted.'); + assert.deepEqual(f.take().filter((c) => c.what === 'publish'), []); + // An open or close that didn't complete names it the same way. + const registry = f.registry as unknown as { + entryAt(ref: ChannelRef): unknown; + lifecycleFailureText(action: 'close', entry: unknown, err: unknown): string; + }; + const failed = registry.lifecycleFailureText('close', registry.entryAt(a), new Error('the wire dropped')); + assert.ok(failed.includes('"a:general"') && !failed.includes('"general"'), failed); +}); + +test("channel_open of a tuned-out channel whose id another server shares names it as tune_out takes it back", async () => { + const f = fixture(both(['a', 'b'])); + f.registry.enterTuneOut('b', 'general', { epochId: 'e', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + const refused = await f.registry.handleChannelToolCall('channel_open', { channelId: 'b:general' }); + assert.ok(refused.error?.includes('Call tune_out with channelId "b:general" and mode "cancel"'), refused.error); + assert.deepEqual(refused.data, { refusal: 'tuned-out', channelId: 'general', serverId: 'b' }); + // An open the tune-out overtook during its round-trip says the same. + const registry = f.registry as unknown as { + entryAt(ref: ChannelRef): unknown; + lifecycleFailureText(action: 'open', entry: unknown, err: unknown): string; + }; + const overtaken = registry.lifecycleFailureText('open', registry.entryAt(b), new Error('the wire dropped')); + assert.ok(overtaken.startsWith('Channel "b:general" was tuned out meanwhile'), overtaken); + assert.ok(overtaken.includes('Call tune_out with channelId "b:general" and mode "cancel"'), overtaken); +}); + +test("a message a server sends for an id only another server registers is refused, not taken as that server's", () => { + // §14.5: channels/incoming is validated at receipt against the channel the + // receiving server registered, not against another server's with the id. + const f = fixture([['b', 'general']]); + f.registry.handleIncoming('a', { messages: [{ + channelId: 'general', messageId: 'm1', author: { id: 'u1', name: 'Antra' }, + timestamp: '2026-05-30T00:00:00.000Z', content: [{ type: 'text', text: 'from a' }], + }] } as never); + assert.ok(f.traces.some((e) => e.type === 'mcpl:channel-incoming-rejected'), 'rejected at receipt'); + assert.equal(f.registry.getDefaultPublishChannel(), null, "b's general doesn't become the default for a's message"); + assert.equal(f.registry.incomingFor(), undefined); +}); + +test("one server's channel declared inbound-only is refused speech, and the other server's channel with its id is not", async () => { + const f = fixture([['a', 'general'], ['b', 'general']]); + await f.registry.handleChanged('a', { updated: [{ id: 'general', type: 'chat', label: 'general', direction: 'inbound' }] } as never); + assert.equal(f.registry.inboundOnlyServer(a), 'a'); + assert.equal(f.registry.inboundOnlyServer(b), undefined); + const refused = await f.registry.routeSpeech('agent', 'to a', a); + assert.equal(refused?.delivered ?? false, false); + const routed = await f.registry.routeSpeech('agent', 'to b', b); + assert.equal(routed?.delivered, true); + assert.deepEqual(f.take().filter((c) => c.what === 'publish').map((c) => `${c.server} ${c.text}`), ['b to b']); +}); + +test("a publish given a server its channel isn't on says where the id is, by the names channelId takes, and gives no advice to post there", async () => { + // One place: the text names its server too, so the caller chooses. + const one = fixture([['a', 'general']]); + const there = await one.registry.handleChannelToolCall('channel_publish', { channelId: 'general', serverId: 'b', content: 'x' }); + assert.equal(there.error, 'No channel "general" is on server "b", so nothing was posted. It\'s on server "a" as "general".'); + assert.equal((await one.registry.publishForAgent({ serverId: 'b', channelId: 'general' }, 'x', 'agent')).error, there.error); + // A place no string picks alone is named as such: a's b:room shares its + // id with c's, and its qualified name with a:b's room. + const some = fixture([['a', 'b:room'], ['c', 'b:room'], ['a:b', 'room']]); + const named = await some.registry.handleChannelToolCall('channel_publish', { channelId: 'b:room', serverId: 'z', content: 'x' }); + assert.equal(named.error, 'No channel "b:room" is on server "z", so nothing was posted. It\'s on server "a" (no channelId ' + + 'reaches it alone) and on server "c" as "c:b:room".'); + // A name that reads two ways gives a line per channel, each with its route + // where a channelId has one. + const twoWays = await some.registry.handleChannelToolCall('channel_publish', { channelId: 'a:b:room', content: 'x' }); + assert.equal(twoWays.error, 'Channel "a:b:room" could mean either of two channels, so nothing was posted:\n' + + '- "b:room" on server "a": no channelId reaches it alone.\n- "room" on server "a:b": call again with channelId "room".'); + // An agent's refusal reads the same: the publish has no route to a + // channel no channelId reaches, and offers none. + const asAgent = { kind: 'agent', agentName: 'scout' } as never; + const byAgent = await some.registry.handleChannelToolCall('channel_publish', { channelId: 'a:b:room', content: 'x' }, asAgent); + assert.equal(byAgent.error, twoWays.error); + // Where no place has a name, the text says where it is and no more. + const none = fixture([['a', 'b:room'], ['b', 'room'], ['a:b', 'room']]); + const listed = await none.registry.handleChannelToolCall('channel_publish', { channelId: 'b:room', serverId: 'z', content: 'x' }); + assert.equal(listed.error, 'No channel "b:room" is on server "z", so nothing was posted. It\'s on server "a" (no channelId reaches it alone).'); + // One server's two channels answering to one name. + const sameServer = fixture([['s', 's:room'], ['s', 'room']]); + const oneServer = await sameServer.registry.handleChannelToolCall('channel_publish', { channelId: 's:room', content: 'x' }); + assert.equal(oneServer.error, 'Channel "s:room" could mean more than one channel on server "s" ("s:s:room", "room"), ' + + 'so nothing was posted. Call again with channelId set to one of them.'); + // Three places, one of which takes no posts: each is named, in registration + // order, and a post there is refused by its own check, by the name that picked it. + const inbound = fixture([['a', 'general'], ['b', 'general'], ['d', 'general']]); + await inbound.registry.handleChanged('b', { updated: [{ id: 'general', type: 'chat', label: 'general', direction: 'inbound' }] } as never); + const readOnly = await inbound.registry.handleChannelToolCall('channel_publish', { channelId: 'general', serverId: 'c', content: 'x' }); + assert.equal(readOnly.error, 'No channel "general" is on server "c", so nothing was posted. It\'s on server "a" as "a:general", on ' + + 'server "b" as "b:general" and on server "d" as "d:general".'); + const qualified = await inbound.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', serverId: 'c', content: 'x' }); + assert.equal(qualified.error, 'No channel "b:general" is on server "c", so nothing was posted. "b:general" alone names channel ' + + '"general" on server "b".'); + const shared = await inbound.registry.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'x' }); + assert.equal(shared.error, 'Channel "general" is on more than one server ("a:general", "b:general", "d:general"), so nothing was ' + + 'posted. Call again with channelId set to one of them.'); + const picked = await inbound.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', content: 'x' }); + assert.match(picked.error ?? '', /^Channel b:general is inbound-only/); + // A tool that doesn't post offers each the same way, and its serverId too. + const closing = await inbound.registry.handleChannelToolCall('channel_close', { channelId: 'general' }); + assert.equal(closing.error, 'Channel "general" is on more than one server ("a:general", "b:general", "d:general"), so nothing ran. ' + + 'Call again with channelId set to one of them, or with serverId set to "a" or "b" or "d".'); + // A serverId given alone, which channel_publish's schema doesn't have, + // names the default channel's id on that server, held to as one given + // with a channelId is: b's general takes no posts, so nothing goes to a's. + (inbound.registry as unknown as { defaultPublishChannel: ChannelRef }).defaultPublishChannel = a; + const alone = await inbound.registry.handleChannelToolCall('channel_publish', { serverId: 'b', content: 'to b' }); + assert.equal(alone.success, false); + assert.match(alone.error!, /^Channel b:general is inbound-only/); + const aloneOwn = await inbound.registry.handleChannelToolCall('channel_publish', { serverId: 'a', content: 'to the default' }); + assert.equal(aloneOwn.success, true, aloneOwn.error); + assert.deepEqual(inbound.take().filter((c) => c.what === 'publish').map((c) => c.server), ['a']); + for (const f of [one, some, none, sameServer]) assert.deepEqual(f.take().filter((c) => c.what === 'publish'), []); +}); +test("a target's refusal lists its candidates in registration order, unmarked, for speech as for a read, though one takes no posts", async () => { + // b registered first, and its general takes no posts, declared in place, so the map's order stays. + const f = fixture(both(['b', 'a'])); + (f.registry as unknown as { channels: Map }).channels + .get(destinationKey(b))!.descriptor.direction = 'inbound'; + for (const forSpeech of [true, false]) { + assert.deepEqual(f.registry.resolveProseTarget('general', { forSpeech }), + { error: 'channel id "general" is ambiguous across servers', candidates: ['b:general', 'a:general'] }, `forSpeech ${forSpeech}`); + } + assert.deepEqual((f.registry.resolveProseTargetDurable('general') as { candidates?: string[] }).candidates, ['b:general', 'a:general']); + // So are one person's DMs on two servers, each by the label that picks it. + const d = fixture([]); + for (const [serverId, label, direction] of [['a', 'DM: antra (work)', 'inbound'], ['b', 'DM: antra (home)', 'bidirectional']] as const) { + await d.registry.handleRegister(serverId, { channels: [{ + id: `discord:dm:${serverId}`, type: 'discord', label, direction, + metadata: { channelType: 'dm', recipientId: '42', recipientName: 'antra' }, + }] } as never); + } + for (const forSpeech of [true, false]) { + assert.deepEqual(d.registry.resolveProseTarget('<@42>', { forSpeech }), + { error: '"<@42>" matches several DMs', candidates: ['DM: antra (work)', 'DM: antra (home)'] }, `forSpeech ${forSpeech}`); + } +}); +test("a host's speech to a ref that resolves nowhere is traced with the server it named", async () => { + const f = fixture(both(['a', 'b'])); + const nowhere = await f.registry.publishForAgent({ serverId: 'c', channelId: 'general' }, 'to c', 'agent'); + // The tool's schema has no serverId, so the refusal names where the id is by the names its channelId takes. + assert.equal(nowhere.error, 'No channel "general" is on server "c", so nothing was posted. It\'s on server "a" as ' + + '"a:general" and on server "b" as "b:general".'); + const failed = f.traces.filter((e) => e.type === 'mcpl:speech-route-failed'); + assert.deepEqual(failed.map((e) => [e.serverId, e.channelId]), [['c', 'general']]); + assert.deepEqual(f.take(), [], 'nothing was posted on a or b'); + // A refusal before the send names the channel the qualified name resolved to. + await f.registry.handleChanged('b', { updated: [{ id: 'general', type: 'chat', label: 'general', direction: 'inbound' }] } as never); + await f.registry.publishForAgent('b:general', 'to b', 'agent'); + assert.deepEqual(f.traces.filter((e) => e.type === 'mcpl:speech-route-failed').map((e) => [e.serverId, e.channelId]).at(-1), ['b', 'general']); +}); + +test("a host's speech is traced at the channel it went to, though the channel is removed while the publish is pending", async () => { + const f = fixture(both(['a', 'b'])); + let release!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const server = (f.registry as unknown as { serverRegistry: { getServer(id: string): { sendChannelsPublish(p: unknown): Promise } } }) + .serverRegistry.getServer('b'); + const send = server.sendChannelsPublish.bind(server); + let entered!: () => void; + const sending = new Promise((resolve) => { entered = resolve; }); + server.sendChannelsPublish = async (p: unknown) => { entered(); await pending; return send(p); }; + const publishing = f.registry.publishForAgent(b, 'to b', 'agent'); + await sending; + f.registry.removeServer('b'); + release(); + await publishing; + const routed = f.traces.filter((e) => e.type === 'mcpl:speech-routed'); + assert.deepEqual(routed.map((e) => [e.serverId, e.channelId]), [['b', 'general']]); +}); + +test("typing asked for a channel not yet registered keeps no metadata for when it is", async () => { + const f = fixture([]); + const late = { serverId: 'a', channelId: 'late' }; + f.registry.startTyping(late, { topic: 'stale' }); + assert.deepEqual(f.take(), [], 'no typing for a channel not registered'); + await f.registry.handleRegister('a', { channels: [{ id: 'late', type: 'chat', label: 'late', direction: 'bidirectional' }] } as never); + f.registry.startTyping(late); + assert.deepEqual(f.take().filter((c) => c.what.startsWith('typing')).map((c) => `${c.server} ${c.what} ${JSON.stringify(c.metadata)}`), + ['a typing undefined']); +}); + +test("two DMs whose labels differ only in case are listed by their tokens, as a name matches either", async () => { + const f = fixture([]); + for (const [serverId, label] of [['a', 'DM: Antra'], ['b', 'DM: antra']] as const) { + await f.registry.handleRegister(serverId, { channels: [{ + id: `discord:dm:${serverId}`, type: 'discord', label, direction: 'bidirectional', + metadata: { channelType: 'dm', recipientId: '42', recipientName: 'antra' }, + }] } as never); + } + assert.deepEqual(f.registry.resolveProseTarget('<@42>'), + { error: '"<@42>" matches several DMs', candidates: ['discord:dm:a', 'discord:dm:b'] }); +}); + +test("speech's candidates for a label two of one server's channels share come in registration order, unmarked, though one takes no posts", async () => { + const f = fixture([['s', 'g1', 'general'], ['s', 'g2', 'general']]); + await f.registry.handleChanged('s', { updated: [{ id: 'g1', type: 'chat', label: 'general', direction: 'inbound' }] } as never); + for (const forSpeech of [true, false]) { + const r = f.registry.resolveProseTarget('general', { forSpeech }); + assert.deepEqual(r, { error: 'label "general" matches several channels', candidates: ['g1', 'g2'] }, `forSpeech ${forSpeech}`); + } +}); + +test("channel_open, channel_close and channel_decline naming the turn's own channel by its id, once its server withdrew it, are refused, and act on no other server's channel", async () => { + const f = fixture(both(['a', 'b'])); + await f.registry.handleChannelToolCall('channel_open', { channelId: 'b:general' }); + await f.registry.handleChanged('a', { removed: ['general'] }); + f.take(); + const inTurn = { kind: 'agent', agentName: 'scout', own: [a] } as never; + for (const [tool, done, extra] of [['channel_open', 'ran', {}], ['channel_close', 'ran', {}], ['channel_decline', 'ran', { messageId: 'm1' }]] as const) { + const r = await f.registry.handleChannelToolCall(tool, { channelId: 'general', ...extra }, inTurn); + assert.equal(r.success, false, tool); + assert.equal(r.error, `"general" is this turn's channel on server "a", which no longer registers it, so nothing ${done}, ` + + 'and nothing can run on it until that server registers it again.', tool); + } + assert.deepEqual(f.take(), [], "nothing went to b's general"); + assert.equal((f.registry as unknown as { getDesiredState(s: string, c: string): string | undefined }).getDesiredState('b', 'general'), 'open'); + // Outside the turn the id is b's alone, as before; and the turn's serverId picks b's, its own choice. + const named = await f.registry.handleChannelToolCall('channel_close', { channelId: 'general', serverId: 'b' }, inTurn); + assert.equal(named.success, true, named.error); +}); + +test("a default channel whose server disconnected is refused as the default gone, naming it, and nothing goes to another server's channel with its id", async () => { + const f = fixture(both(['a', 'b'])); + const internal = f.registry as unknown as { defaultPublishChannel: ChannelRef | null }; + internal.defaultPublishChannel = a; + f.registry.removeServer('a'); + assert.deepEqual(internal.defaultPublishChannel, a, "removeServer keeps the default's ref, as a withdrawal does"); + const gone = await f.registry.handleChannelToolCall('channel_publish', { content: 'x' }); + assert.equal(gone.error, 'The channel this would default to, "a:general", isn\'t registered now, so nothing was posted.'); + assert.deepEqual(f.take().filter((c) => c.what === 'publish'), []); +}); + +test("a tool call takes the turn's channels only when it carries the turn's id", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + turnRouting: Map; + callOwnChannels(agent: string, call: { id: string; name: string; input: object; callerTurnId?: number }): ChannelRef[]; + }; + fw.turnRouting.set('scout', { turnId: 7, wasIn: [a], triggerChannel: a, engaged: new Set() }); + assert.deepEqual(fw.callOwnChannels('scout', { id: 'now', name: 'channel_publish', input: {}, callerTurnId: 7 }), [a], "the turn's own call"); + assert.deepEqual(fw.callOwnChannels('scout', { id: 'earlier', name: 'channel_publish', input: {}, callerTurnId: 6 }), [], "an earlier turn's call"); + assert.deepEqual(fw.callOwnChannels('scout', { id: 'none', name: 'channel_publish', input: {} }), [], 'a call with no turn'); + // A silent start's routing is the latest while turn 7 is still open: turn 7's call is still its own. + (fw as unknown as { openLogical: Map }).openLogical.set('scout', fw.turnRouting.get('scout')); + fw.turnRouting.set('scout', { turnId: 8, wasIn: [], engaged: new Set() }); + assert.deepEqual(fw.callOwnChannels('scout', { id: 'open', name: 'channel_publish', input: {}, callerTurnId: 7 }), [a], "the open turn's call"); + assert.deepEqual(fw.callOwnChannels('scout', { id: 'silent', name: 'channel_publish', input: {}, callerTurnId: 8 }), [], "the silent start's own call, which has no channel"); +}); + +test("channel_open, channel_close and channel_decline naming the turn's own channel by a shared id take that channel, and two of the turn's channels with the id mean neither", async () => { + const f = fixture(both(['a', 'b'])); + const inTurn = (own: ChannelRef[]) => ({ kind: 'agent', agentName: 'scout', own }) as never; + const opened = await f.registry.handleChannelToolCall('channel_open', { channelId: 'general' }, inTurn([b])); + assert.equal(opened.success, true, opened.error); + assert.deepEqual((opened.data as { serverId?: string }).serverId, 'b'); + const closed = await f.registry.handleChannelToolCall('channel_close', { channelId: 'general' }, inTurn([b])); + assert.equal(closed.success, true, closed.error); + assert.deepEqual(f.take().filter((c) => c.what === 'open' || c.what === 'close').map((c) => `${c.what} ${c.server}`), ['open b', 'close b']); + for (const tool of ['channel_open', 'channel_close', 'channel_decline']) { + const neither = await f.registry.handleChannelToolCall(tool, { channelId: 'general', messageId: 'm1' }, inTurn([a, b])); + assert.equal(neither.error, '"general" is the id of this turn\'s channels on more than one server ("a:general", "b:general"), ' + + 'so nothing ran. Call again with channelId set to one of them.', tool); + } + // A name no string picks alone: server "a" has "b:room" and server "a:b" has "room", both answering to "a:b:room". + const odd = fixture([['a', 'b:room'], ['a:b', 'room'], ['c', 'b:room']]); + const ownOdd: ChannelRef[] = [{ serverId: 'a', channelId: 'b:room' }, { serverId: 'c', channelId: 'b:room' }]; + const oddRefusal = await odd.registry.handleChannelToolCall('channel_publish', { channelId: 'b:room', content: 'x' }, inTurn(ownOdd)); + assert.equal(oddRefusal.error, '"b:room" is the id of this turn\'s channels on more than one server ("b:room" on server "a" (no channelId ' + + 'reaches it alone), "c:b:room"), so nothing was posted. Call again with channelId "c:b:room".'); + // None reachable by a name: no "Call again". + await odd.registry.handleChanged('c', { removed: ['b:room'] }); + const none = await odd.registry.handleChannelToolCall('channel_publish', { channelId: 'b:room', content: 'x' }, inTurn(ownOdd)); + assert.equal(none.error, '"b:room" is the id of this turn\'s channels on more than one server ("b:room" on server "a" (no channelId ' + + 'reaches it alone), "b:room" on server "c" (its server no longer registers it)), so nothing was posted.'); + // Two named among three: "one of X, Y". + const three = fixture([['a', 'general'], ['b', 'general'], ['d', 'general']]); + await three.registry.handleChanged('d', { removed: ['general'] }); + const d = { serverId: 'd', channelId: 'general' }; + const two = await three.registry.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'x' }, inTurn([a, b, d])); + assert.equal(two.error, '"general" is the id of this turn\'s channels on more than one server ("a:general", "b:general", "general" on ' + + 'server "d" (its server no longer registers it)), so nothing was posted. Call again with channelId set to one of "a:general", "b:general".'); +}); + +test("a default channel's reply anchors survive a withdrawal, as on main, and go with its server's disconnect, as on main, while its ref stays", async () => { + const f = fixture(both(['a', 'b'])); + f.registry.handleIncoming('a', { messages: [{ channelId: 'general', messageId: 'm-a', threadId: 't-a', author: { id: 'u', name: 'u' }, + timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'hi' }] }] } as never); + const before = f.registry.incomingFor(); + assert.deepEqual(before, { channel: a, messageId: 'm-a', threadId: 't-a' }); + await f.registry.handleChanged('a', { removed: ['general'] }); + assert.deepEqual(f.registry.incomingFor(), before, 'a withdrawal keeps them'); + f.registry.removeServer('a'); + assert.equal(f.registry.incomingFor(), undefined, "a disconnect takes them, so no message on a server that's gone is given as the one replied to"); + assert.deepEqual(f.registry.getDefaultPublishChannel(), a, "the default's ref stays, for the default-gone refusal"); +}); + +test("the subconscious's cancel_tuneout gets its turn's own channels from the framework, for a call made inside the turn", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const seen: Array = []; + const fw = f.framework as unknown as { + subconsciousAgentName: string | undefined; + tuneOutCoordinator: unknown; + turnRouting: Map; + dispatchToolCall(agent: string, call: { id: string; name: string; input: object }, turnId?: number): void; + }; + fw.subconsciousAgentName = 'scout'; + fw.tuneOutCoordinator = { + handleSubconsciousTool: async (_name: string, _input: unknown, own?: readonly ChannelRef[]) => { seen.push(own); return { success: true }; }, + stop: () => {}, + }; + fw.turnRouting.set('scout', { turnId: 7, wasIn: [b], triggerChannel: b, engaged: new Set() }); + fw.dispatchToolCall('scout', { id: 'c1', name: 'cancel_tuneout', input: { channelId: 'general' } }, 7); + fw.dispatchToolCall('scout', { id: 'c2', name: 'cancel_tuneout', input: { channelId: 'general' } }, 6); + for (let i = 0; i < 20 && seen.length < 2; i++) await new Promise((r) => setImmediate(r)); + assert.deepEqual(seen, [[b], []], "the turn's own call gets its channels; an earlier turn's gets none"); +}); + +test("channel_open's answer when its decision can't be recorded names the shared channel as the agent is shown it", async () => { + const f = fixture([['a', 'general'], ['b', 'general']]); + const first = await f.registry.handleChannelToolCall('channel_open', { channelId: 'a:general' }); + assert.equal(first.success, true, first.error); + (f.registry as unknown as { setDesiredState(): void }).setDesiredState = () => { throw new Error('ENOSPC'); }; + const again = await f.registry.handleChannelToolCall('channel_open', { channelId: 'a:general' }); + assert.equal(again.error, 'Channel a:general is already open, but recording your decision to open it failed: ENOSPC'); +}); + +test('the channel map keeps two channels whose ids would join to one key with a colon, each with its own entry', async () => { + // Server `a`'s channel `b:room` and server `a:b`'s channel `room` both read + // `a:b:room` once joined with a colon. + const f = fixture([]); + await f.registry.handleRegister('a', { channels: [{ id: 'b:room', type: 'chat', label: "a's b:room", direction: 'bidirectional' }] } as never); + await f.registry.handleRegister('a:b', { channels: [{ id: 'room', type: 'chat', label: "a:b's room", direction: 'bidirectional' }] } as never); + assert.equal(f.registry.getDescriptor({ serverId: 'a', channelId: 'b:room' })?.label, "a's b:room"); + assert.equal(f.registry.getDescriptor({ serverId: 'a:b', channelId: 'room' })?.label, "a:b's room"); + const listed = await f.registry.handleChannelToolCall('channel_list', {}); + assert.deepEqual((listed.data as Array<{ id: string; serverId: string }>).map((c) => `${c.serverId} ${c.id}`).sort(), ['a b:room', 'a:b room']); + + // Each id is unique, so each names its channel. + assert.equal(f.registry.nameShown({ serverId: 'a', channelId: 'b:room' }), 'b:room'); + // The joined name means both, so it's refused with each named by its server and id apart. + const refused = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'a:b:room', content: 'x' }); + assert.equal(refused.success, false); + assert.equal(refused.error, + 'Channel "a:b:room" is on more than one server ("b:room", "room"), so nothing was posted. Call again with channelId set to one of them.'); +}); + +test("a name that reads two ways is refused with a line per channel, each with its route where the tool has one", async () => { + // Server `a` and server `c` both register `b:room`, and server `a:b` + // registers `room`: `a`'s `b:room` has no string of its own, since `b:room` + // is shared and `a:b:room` is also `a:b`'s `room`. + const f = fixture([['a', 'b:room'], ['c', 'b:room'], ['a:b', 'room']]); + assert.deepEqual(f.registry.channelsNamed('a:b:room').map((r) => `${r.serverId} ${r.channelId}`).sort(), ['a b:room', 'a:b room']); + // a:b's `room` is the only `room`, so that names it alone; a's `b:room` has no name of its own. + // A publish says nothing was posted; another tool, that nothing ran. + const opening = (nothing: string) => `Channel "a:b:room" could mean either of two channels, so ${nothing}:\n`; + const refused = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'a:b:room', content: 'x' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(refused.error, opening('nothing was posted') + + '- "b:room" on server "a": no channelId reaches it alone.\n' + + '- "room" on server "a:b": call again with channelId "room".'); + // The subconscious's refusal reads the same. + const unspoken = await f.registry.publishForAgent('a:b:room', 'x', 'Subconscious'); + assert.equal(unspoken.error, opening('nothing was posted') + + '- "b:room" on server "a": no channelId reaches it alone.\n' + + '- "room" on server "a:b": call again with channelId "room".'); + assert.equal(unspoken.reason, 'shared-id', 'a name that reads two ways is a shared name too'); + // A tool that takes serverId reaches each by its id and server. + const closeRefused = await f.registry.handleChannelToolCall('channel_close', { channelId: 'a:b:room' }); + assert.equal(closeRefused.error, opening('nothing ran') + + '- "b:room" on server "a": call again with channelId "b:room" and serverId "a".\n' + + '- "room" on server "a:b": call again with channelId "room".'); + assert.equal(f.registry.proseTargetFor({ serverId: 'a', channelId: 'b:room' }), undefined, 'no token picks it alone'); + // What an agent is shown names each by what picks it alone, and the one + // nothing picks alone by its id and server, as the refusals name it. + assert.equal(f.registry.nameShown({ serverId: 'a', channelId: 'b:room' }), '"b:room" on server "a"'); + assert.equal(f.registry.nameShown({ serverId: 'c', channelId: 'b:room' }), 'c:b:room'); + assert.equal(f.registry.nameShown({ serverId: 'a:b', channelId: 'room' }), 'room'); + // tune_out takes a name, so a tuned-out channel no name picks can't be + // cancelled by it, and the refusal says how its tune-out ends instead. + f.registry.enterTuneOut('a', 'b:room', { epochId: 'e', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); + const tuned = await f.registry.handleChannelToolCall('channel_open', { channelId: 'b:room', serverId: 'a' }); + assert.ok(tuned.error?.startsWith('Channel "b:room" on server "a" is tuned out'), tuned.error); + assert.ok(tuned.error?.includes("No channelId names it alone, so tune_out can't cancel it"), tuned.error); + // channel_close refuses it too, and says the close can come once the + // tune-out has ended by itself. + const closing = await f.registry.handleChannelToolCall('channel_close', { channelId: 'b:room', serverId: 'a' }); + assert.ok(closing.error?.startsWith('Channel "b:room" on server "a" is tuned out'), closing.error); + assert.ok(closing.error?.endsWith('and its backlog is delivered then. Once it has ended, channel_close can close it.'), closing.error); + // An open the tune-out overtook during its round-trip says the same, not + // to call tune_out with a name none is. + const registry = f.registry as unknown as { + entryAt(ref: ChannelRef): unknown; + lifecycleFailureText(action: 'open', entry: unknown, err: unknown): string; + }; + const overtaken = registry.lifecycleFailureText('open', registry.entryAt({ serverId: 'a', channelId: 'b:room' }), new Error('the wire dropped')); + assert.ok(overtaken.startsWith('Channel "b:room" on server "a" was tuned out meanwhile'), overtaken); + assert.ok(overtaken.includes("No channelId names it alone, so tune_out can't cancel it") && !overtaken.includes('Call tune_out'), overtaken); + // channel_list gives a name only where one picks the channel alone. + const rows = (await f.registry.handleChannelToolCall('channel_list', {})).data as Array<{ id: string; name?: string; serverId: string }>; + assert.deepEqual(rows.map((r) => [r.serverId, r.id, r.name]), [['a', 'b:room', undefined], ['c', 'b:room', 'c:b:room'], ['a:b', 'room', undefined]]); + assert.deepEqual(f.take(), []); +}); + +test("a name one server's two channels answer to is refused without offering the server, and a bare id another channel's qualified name shares is refused too", async () => { + // Server `s` registers `s:room` and `room`: `s:room` is the first's id and the second's qualified name. + const f = fixture([['s', 's:room'], ['s', 'room']]); + const close = await f.registry.handleChannelToolCall('channel_close', { channelId: 's:room' }); + assert.equal(close.error, 'Channel "s:room" could mean more than one channel on server "s" ("s:s:room", "room"), so nothing ran. ' + + 'Call again with channelId set to one of them.'); + // A >> target and the publish resolver say the same of it. + const target = f.registry.resolveProseTarget('s:room'); + assert.deepEqual(target, { error: 'channel id "s:room" names more than one channel on server "s"', candidates: ['s:s:room', 'room'] }); + assert.deepEqual(f.registry.resolveDestination({ channelId: 's:room' }), + { error: 'channel id "s:room" names more than one channel on server "s" ("s:s:room", "room")' }); + // With no id shared at all: server `a`'s `b:room` and server `b`'s `room`. + const g = fixture([['a', 'b:room'], ['b', 'room']]); + const publish = await g.registry.handleChannelToolCall('channel_publish', { channelId: 'b:room', content: 'x' }); + assert.equal(publish.error, 'Channel "b:room" is on more than one server ("a:b:room", "room"), so nothing was posted. ' + + 'Call again with channelId set to one of them.'); + // A serverId goes with a channel's own id, which differs between these two, so none is offered. + const closeAcross = await g.registry.handleChannelToolCall('channel_close', { channelId: 'b:room' }); + assert.equal(closeAcross.error, 'Channel "b:room" is on more than one server ("a:b:room", "room"), so nothing ran. ' + + 'Call again with channelId set to one of them.'); + assert.deepEqual(g.registry.resolveDestination({ channelId: 'b:room' }), + { error: 'channel id "b:room" is on more than one server ("a:b:room", "room"), and the destination named no server' }); + assert.deepEqual(g.registry.resolveDestination({ channelId: 'a:b:room' }), + { destination: { serverId: 'a', channelId: 'b:room', label: 'b:room' } }, 'its qualified name picks it'); + assert.deepEqual(g.take(), []); +}); + +test("a call that gives serverId names the exact pair, and its refusal says which call would work", async () => { + const f = fixture([...both(['a', 'b']), ['a', 'b:room'], ['b', 'room']]); + // A serverId goes with the channel's own id. + const closed = await f.registry.handleChannelToolCall('channel_close', { channelId: 'general', serverId: 'b' }); + assert.equal(closed.success, true, closed.error); + assert.deepEqual(f.take().filter((c) => c.what === 'close').map((c) => `${c.server} ${c.channelId}`), ['b general']); + // A qualified name with its serverId isn't the pair, and the refusal says the name works alone. + const both_ = await f.registry.handleChannelToolCall('channel_close', { channelId: 'a:general', serverId: 'a' }); + assert.equal(both_.error, 'No channel "a:general" is on server "a", so nothing ran. "a:general" alone names channel ' + + '"general" on server "a": for that one, call again with serverId left out.'); + // An id on another server: leaving serverId out is offered only where the id alone names one channel. + const elsewhere = await f.registry.handleChannelToolCall('channel_close', { channelId: 'b:room', serverId: 'c' }); + assert.equal(elsewhere.error, 'No channel "b:room" is on server "c", so nothing ran. It\'s on server "a": call again with serverId "a".'); + // A canonical ref whose channel is gone isn't read as another channel's qualified name. + const gone = await f.registry.publishForAgent({ serverId: 'b', channelId: 'b:room' }, 'x', 'agent'); + assert.equal(gone.success, false); + assert.deepEqual(f.take().filter((c) => c.what === 'publish'), [], "b's room, whose qualified name is b:room, isn't posted to"); +}); + +test('a channel no longer registered is shown by a name no registered channel answers to', () => { + // Server `b` registers `general`, and server `x` a channel whose id is `a:general`. + const f = fixture([['b', 'general'], ['x', 'a:general']]); + // By its id where nothing registered answers to it... + assert.equal(f.registry.nameShown({ serverId: 'a', channelId: 'room' }), 'room'); + // ...qualified where its id now names another server's channel... + assert.equal(f.registry.nameShown({ serverId: 'c', channelId: 'general' }), 'c:general'); + // ...and by its id and server where something answers to both. + assert.equal(f.registry.nameShown({ serverId: 'a', channelId: 'general' }), '"general" on server "a"'); + // A >> target reads labels too: a name a label takes would send elsewhere. + const g = fixture([['c', 'c:9', '#lounge']]); + assert.equal(g.registry.nameShown({ serverId: 'a', channelId: 'lounge' }), 'a:lounge', '">>lounge" would reach c\'s #lounge'); +}); + +test("a DM is listed by its label only where the label, given as a target, picks that DM", async () => { + // Server x's channel has the id `inbox-a`, which a's DM is labelled: + // `>>inbox-a` reaches x's channel, since an id outranks a label. + const f = fixture([['x', 'inbox-a']]); + for (const serverId of ['a', 'b']) { + await f.registry.handleRegister(serverId, { channels: [{ + id: `discord:dm:${serverId}`, type: 'discord', label: `inbox-${serverId}`, direction: 'bidirectional', + metadata: { channelType: 'dm', recipientId: '42', recipientName: 'antra' }, + }] } as never); + } + assert.deepEqual(f.registry.resolveProseTarget('<@42>'), + { error: '"<@42>" matches several DMs', candidates: ['discord:dm:a', 'inbox-b'] }); + // Each candidate, given as a target, names its own DM. + for (const [candidate, serverId] of [['discord:dm:a', 'a'], ['inbox-b', 'b']] as const) { + const named = f.registry.resolveProseTarget(candidate); + assert.equal('serverId' in named ? named.serverId : named.error, serverId, candidate); + } +}); + +test("a DM labelled as an @name is listed by its token, since that label as a target would read as a DM name", async () => { + const f = fixture([]); + for (const [serverId, label] of [['a', '@antra'], ['b', '@antra (home)']] as const) { + await f.registry.handleRegister(serverId, { channels: [{ + id: `discord:dm:${serverId}`, type: 'discord', label, direction: 'bidirectional', + metadata: { channelType: 'dm', recipientId: '42', recipientName: 'antra' }, + }] } as never); + } + assert.deepEqual(f.registry.resolveProseTarget('<@42>'), + { error: '"<@42>" matches several DMs', candidates: ['discord:dm:a', 'discord:dm:b'] }); +}); + +test("a channel whose id is no >> target is offered by its qualified name", () => { + const f = fixture([['s', 'skip_reply', 'Skip Reply']]); + assert.equal(f.registry.proseTargetFor({ serverId: 's', channelId: 'skip_reply' }), 's:skip_reply'); +}); + +test("stopping everything forgets each channel's typing metadata, so the channel registered again types without it", async () => { + const f = fixture([['a', 'general']]); + f.registry.startTyping(a, { topic: 'before the stop' }); + f.registry.stopAll(); + await f.registry.handleRegister('a', { channels: [{ id: 'general', type: 'chat', label: 'general', direction: 'bidirectional' }] } as never); + f.take(); + f.registry.startTyping(a); + assert.deepEqual(f.take().filter((c) => c.what === 'typing').map((c) => `${c.server} ${c.channelId} ${JSON.stringify(c.metadata)}`), + ['a general undefined']); +}); + +test("a publish's failed or unconfirmed answer names its channel apart, as it's shown once the answer comes", async () => { + const f = fixture(both(['a', 'b'])); + const server = (f.registry as unknown as { serverRegistry: { getServer(id: string): { sendChannelsPublish(p: unknown): Promise } } }) + .serverRegistry.getServer('b'); + server.sendChannelsPublish = async () => ({ delivered: false }); + const failed = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', content: 'x' }); + assert.equal(failed.error, 'Nothing was posted to channel "b:general": the "b" server said it didn\'t post the message.'); + server.sendChannelsPublish = async () => { throw new Error('the wire dropped'); }; + const unknown = await f.registry.handleChannelToolCall('channel_publish', { channelId: 'b:general', content: 'x' }); + assert.match(unknown.error ?? '', /^Posting to channel "b:general" wasn't confirmed: /); + // A channel whose id another server registers while its send is out is + // named apart once the answer comes, not by the bare id that now names + // the other server's channel. + const g = fixture([['a', 'general']]); + const gServer = (g.registry as unknown as { serverRegistry: { getServer(id: string): { sendChannelsPublish(p: unknown): Promise } } }) + .serverRegistry.getServer('a'); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const sending = new Promise((resolve) => { entered = resolve; }); + gServer.sendChannelsPublish = async () => { entered(); await held; return { delivered: false }; }; + const publishing = g.registry.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'x' }); + await sending; + g.registry.removeServer('a'); + await g.registry.handleRegister('b', { channels: [{ id: 'general', type: 'chat', label: 'general', direction: 'bidirectional' }] } as never); + release(); + assert.equal((await publishing).error, 'Nothing was posted to channel "a:general": the "a" server said it didn\'t post the message.'); + // So is an unconfirmed answer. + const h = fixture([['a', 'general']]); + const hServer = (h.registry as unknown as { serverRegistry: { getServer(id: string): { sendChannelsPublish(p: unknown): Promise } } }) + .serverRegistry.getServer('a'); + let releaseH!: () => void; + const heldH = new Promise((resolve) => { releaseH = resolve; }); + let enteredH!: () => void; + const sendingH = new Promise((resolve) => { enteredH = resolve; }); + hServer.sendChannelsPublish = async () => { enteredH(); await heldH; throw new Error('the wire dropped'); }; + const publishingH = h.registry.handleChannelToolCall('channel_publish', { channelId: 'general', content: 'x' }); + await sendingH; + h.registry.removeServer('a'); + await h.registry.handleRegister('b', { channels: [{ id: 'general', type: 'chat', label: 'general', direction: 'bidirectional' }] } as never); + releaseH(); + assert.match((await publishingH).error ?? '', /^Posting to channel "a:general" wasn't confirmed: /); +}); + +test("a closed-channel invitation offers no >> reply a line reads as private speech, with the real registry or without its proseTargetFor", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + // A stand-in registry that can't name targets: the framework guesses one. + (f.i.channelRegistry as unknown as { proseTargetFor?: unknown }).proseTargetFor = undefined; + const invite = (f.framework as unknown as { buildClosedChannelInvitation(o: unknown): unknown }).buildClosedChannelInvitation({ + serverId: 's', channelId: 'skip_reply', messageId: 'm1', authorName: 'antra', tags: ['chat:addressed'], + }); + 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('>>skip_reply'), text); + // The real registry offers the channel by its server-qualified name, which + // a >> line reads as a target. + delete (f.i.channelRegistry as unknown as { proseTargetFor?: unknown }).proseTargetFor; + (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 's', channelId: 'skip_reply' }), { + serverId: 's', descriptor: { id: 'skip_reply', type: 'chat', label: '' }, open: false, + }); + const real = JSON.stringify((f.framework as unknown as { buildClosedChannelInvitation(o: unknown): unknown }).buildClosedChannelInvitation({ + serverId: 's', channelId: 'skip_reply', messageId: 'm2', authorName: 'antra', tags: ['chat:addressed'], + })); + assert.ok(real.includes('>>s:skip_reply'), real); + assert.ok(!real.includes('>>skip_reply'), real); +}); + +test("a decline whose decision isn't recorded names its channel apart", async () => { + const f = fixture(both(['a', 'b'])); + const internal = f.registry as unknown as { channels: Map; setDesiredState: () => void }; + internal.channels.get(destinationKey(b))!.open = false; + internal.setDesiredState = () => { throw new Error('the disk is full'); }; + const failed = await f.registry.handleChannelToolCall('channel_decline', { channelId: 'b:general', messageId: 'm1' }); + assert.match(failed.error ?? '', /^Nothing changed for channel "b:general": /); +}); + +test("a fork's turn whose wake names no channel still has its home, so it's replying to the latest message as every fork's turn is", () => { + const f = fixture(both(['a', 'b'])); + Object.assign(f.registry as unknown as Record, { + activeChannelResolver: () => null, + homeChannelResolver: (name: string) => (name === 'fork' ? b : undefined), + defaultPublishChannel: a, + defaultPublishMessageId: 'm-latest', + }); + assert.equal(f.registry.incomingFor('scout'), undefined, 'a turn with no channel at all has none'); + assert.deepEqual(f.registry.incomingFor('fork'), { channel: a, messageId: 'm-latest', threadId: undefined }); +}); + +test('beforeInference gives each server its own channel context in place of the shared one', async (t) => { + const sent: Array<{ server: string; channels: unknown }> = []; + const server = (id: string) => ({ + id, + grant: new CapabilityGrant(new Set(['contextHooks.beforeInference.observe']), []), + sendBeforeInference: async (params: { channels?: unknown }) => { + sent.push({ server: id, channels: params.channels }); + return { contextInjections: [] }; + }, + }); + const servers = [server('a'), server('b')]; + const orchestrator = new HookOrchestrator({ getAllServers: () => servers } as never, {} as never); + await orchestrator.beforeInference( + { inferenceId: 'i', conversationId: 'agent', turnIndex: 0, userMessage: null, model: { id: 'm' }, channels: { candidates: ['shared'] } } as never, + (serverId) => (serverId === 'b' ? { defaultOutgoing: { channelId: 'general' } } : undefined), + ); + assert.deepEqual(sent.sort((x, y) => x.server.localeCompare(y.server)), [ + { server: 'a', channels: undefined }, + { server: 'b', channels: { defaultOutgoing: { channelId: 'general' } } }, + ]); + + // A throw building one server's context fails that server alone, and says so. + sent.length = 0; + const logged = t.mock.method(console, 'error', () => {}); + await orchestrator.beforeInference( + { inferenceId: 'i2', conversationId: 'agent', turnIndex: 0, userMessage: null, model: { id: 'm' } } as never, + (serverId) => { if (serverId === 'a') throw new Error('no context for a'); return undefined; }, + ); + assert.deepEqual(sent.map((x) => x.server), ['b']); + assert.match(String(logged.mock.calls[0]?.arguments[0]), /the channel context for "a" couldn't be built/); +}); + +// ── Through the framework: a turn's channel carries its server ── + +type FrameworkInternals = { + initializeMcpl(servers: []): Promise; + channelRegistry: ChannelRegistry; + mcplServerRegistry: McplServerRegistry; + pendingRequests: InferenceRequest[]; + turnRouting: Map; +}; + +/** A framework with a real channel registry, whose servers each register + * `general`, open, in the order given, and record what they're sent. */ +async function frameworkWith( + t: TestContext, order: string[], proseRouting?: 'explicit' | 'hybrid', refusesOn?: string, withGate?: boolean, + agentOptions: { maxStreamTokens?: number } = {}, +) { + const dir = mkdtempSync(join(tmpdir(), 'af-shared-id-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', ...(proseRouting ? { proseRouting } : {}), ...agentOptions }], + modules: [], + ...(withGate ? { gate: { config: { policies: [], default: 'skip' as const } } } : {}), + }); + // Stopped before its store's directory goes (the hooks run in the order added). + t.after(async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }); + const i = framework as unknown as FrameworkInternals; + await i.initializeMcpl([]); + const calls: string[] = []; + const beforeInference: Array<{ server: string; channels: unknown }> = []; + const servers = new Map(order.map((id) => [id, { + id, + isConnected: true, + policyEstablished: true, + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (p: { channelId: string; content: Array<{ text: string }> }) => { + calls.push(`${id} publish ${p.channelId}: ${p.content[0]!.text}`); + // A server named in refusesOn answers that nothing was posted. + return id === refusesOn ? { delivered: false } : { delivered: true, messageId: `${id}-${calls.length}` }; + }, + sendChannelsTyping: (channelId: string, _metadata: unknown, op?: string) => { + calls.push(`${id} typing${op === 'stop' ? '-stop' : ''} ${channelId}`); + }, + sendChannelsOpen: async () => ({}), + sendBeforeInference: async (p: { channels?: unknown }) => { + beforeInference.push({ server: id, channels: p.channels }); + return { contextInjections: [] }; + }, + }])); + t.mock.method(i.mcplServerRegistry, 'getServer', (id: string) => servers.get(id) as never); + const map = (i.channelRegistry as unknown as { channels: Map }).channels; + for (const serverId of order) { + map.set(destinationKey({ serverId, channelId: 'general' }), { + serverId, descriptor: { id: 'general', type: 'chat', label: '#general' }, open: true, + }); + } + const notices = () => framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + // The hooks fan out to every server only where a test asks for it. + const fanOutToServers = () => t.mock.method(i.mcplServerRegistry, 'getAllServers', () => [...servers.values()] as never); + return { framework, membrane, calls, i, notices, beforeInference, fanOutToServers }; +} + +function incomingOn(serverId: string, text: string, metadata: Record = {}): ProcessEvent { + return { + type: 'mcpl:channel-incoming', + serverId, + channelId: 'general', + messageId: `m-${serverId}-${Math.random().toString(36).slice(2)}`, + author: { id: 'U1', name: 'antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata, + triggerInference: true, + } as unknown as ProcessEvent; +} + +for (const order of orders) { + const registered = `registered ${order.join(' then ')}`; + + test(`a reply to a message on b's general goes to b, with typing there and the server named (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + // The traces that tag the turn and its text with its channel name it by + // its id, a string, as trace consumers read it (connectome-host's + // tts-relay takes a string only). + const traced: Array<[string, unknown]> = []; + const started: Array<{ channelId?: unknown; serverId?: unknown }> = []; + f.framework.onTrace((e) => { + if (e.type === 'inference:started' || e.type === 'inference:tokens' || e.type === 'inference:content_block') { + traced.push([e.type, (e as { channelId?: unknown }).channelId]); + } + if (e.type === 'inference:started') started.push(e as { channelId?: unknown; serverId?: unknown }); + }); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the reply' }])); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + assert.deepEqual(f.i.turnRouting.get('scout')?.locus, b); + assert.ok(traced.some(([type]) => type === 'inference:tokens'), 'premise: the text was traced'); + assert.deepEqual(traced.filter(([, channelId]) => channelId !== 'general'), [], 'each trace names the channel by its id'); + // The stream's start names the channel's server too, so a host can post + // back to the turn's channel where both servers register its id. + assert.deepEqual(started.map((e) => [e.channelId, e.serverId]), [['general', 'b']]); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), ['b publish general: the reply']); + assert.ok(f.calls.some((c) => c === 'b typing general'), 'typing shows on b'); + assert.ok(!f.calls.some((c) => c.startsWith('a ')), 'a is sent nothing'); + assert.ok(f.notices().some((n) => n.includes('#general (b:general)')), "the routing notice names b's channel"); + }); + + test(`explicit mode shows typing on the channel the wake came from, on its server (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit'); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + assert.ok(f.calls.includes('b typing general'), f.calls.join('\n')); + assert.ok(!f.calls.some((c) => c.startsWith('a ')), 'a is sent nothing'); + }); + + test(`explicit mode's typing stays where the wake came from at the turn's later start, though a channel_open moved its default channel (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit', undefined, false, { maxStreamTokens: 20 }); + // Round one opens a's general and crosses the stream's budget, so the + // turn starts again, a start whose trigger names no channel. + const overBudget = createMockResponse([ + { type: 'tool_use', id: 'open-a', name: 'channel_open', input: { channelId: 'a:general' } }, + ] as never, 'tool_use'); + overBudget.usage = { inputTokens: 100, outputTokens: 5 }; + f.membrane.pushResponse(overBudget); + // The restart's response is queued when it starts, so the stream it + // replaces can't take it. + let starts = 0; + const traced: unknown[] = []; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts === 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + } + const t = e as { type: string; blockType?: string; channelId?: unknown }; + if (t.type === 'inference:tokens' && t.blockType === 'text') traced.push(t.channelId); + }); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + assert.equal(starts, 2, 'control: the turn started again for its budget'); + assert.deepEqual(f.i.turnRouting.get('scout')?.triggerChannel, a, "control: the open moved the turn's default channel"); + assert.ok(f.calls.includes('b typing general'), f.calls.join('\n')); + assert.ok(!f.calls.includes('a typing general'), `typing never shows in the opened channel: ${f.calls.join('\n')}`); + // The restart's own trigger names no channel: it keeps what the turn + // shows, and its text's traces name it. + assert.ok(traced.length > 0, "control: the restart's text was traced"); + assert.deepEqual([...new Set(traced)], ['general'], "the restart's traces name the channel its typing shows"); + assert.deepEqual((f.i.turnRouting.get('scout') as { typing?: ChannelRef } | undefined)?.typing, b); + }); + + test(`a >> target a shared id names bounces, with its candidates and an example built from them (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit'); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>general hello' }])); + // The bounce wakes the agent again, and it lets the draft be. + let starts = 0; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts >= 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + } + }); + // A turn no message triggered: none of its own channels has the id. + f.framework.nudgeAgent('scout', 'a nudge with no channel'); + await f.framework.runUntilIdle(); + const bounces = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + assert.equal(bounces.length, 1, bounces.join('\n')); + assert.ok(bounces[0]!.includes(`Known channels it could mean: ${order.map((s) => `${s}:general`).join(', ')}.`), bounces[0]); + assert.ok(bounces[0]!.includes(`">>${order[0]}:general {{unsent}}"`), 'the example names a candidate as a target takes it'); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], 'nothing was posted'); + }); + + test(`a >> target the id of the channel the turn's message came from goes there, on its server, though the other server has the id too (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit'); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>general hello b' }])); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), ['b publish general: hello b'], f.calls.join('\n')); + }); + + test(`a turn replying on b's general tells each server only of its own channels, through the framework's hook (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + f.fanOutToServers(); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello b' }])); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + // The turn's own channel, by the framework's callback: b's general. + const told = new Map(f.beforeInference.map((x) => [x.server, x.channels as { incoming?: unknown; defaultOutgoing?: unknown } | undefined])); + assert.ok(told.has('a') && told.has('b'), 'both servers were asked'); + assert.deepEqual(told.get('b')?.defaultOutgoing, { channelId: 'general' }, 'b is told the turn speaks in its general'); + assert.equal(told.get('a')?.defaultOutgoing, undefined, "a isn't told that its general is the turn's"); + }); + + test(`a batch's turn takes its channel and that channel's server from one request (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + const [ambient, addressed] = order as [string, string]; + // Ambient chatter on one server's general, then an addressed message on + // the other's: the addressed one is the turn's channel, with its server. + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'on it' }])); + const now = Date.now(); + f.i.pendingRequests.push( + { agentName: 'scout', reason: 'chatter', source: 'mcpl', timestamp: now, channelId: 'general', channelServerId: ambient }, + { agentName: 'scout', reason: 'mention', source: 'mcpl', timestamp: now + 1, channelId: 'general', channelServerId: addressed, addressed: true }, + ); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1, 'the two requests ran as one turn'); + assert.deepEqual(f.i.turnRouting.get('scout')?.triggerChannel, { serverId: addressed, channelId: 'general' }); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [`${addressed} publish general: on it`]); + }); + + test(`a bounce whose candidates aren't one token each gives the generic example (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit'); + // One person's DMs on two servers, labelled apart: listed by their labels, which hold spaces. + const map = (f.i.channelRegistry as unknown as { channels: Map }).channels; + for (const [serverId, label] of [['a', 'DM: antra (work)'], ['b', 'DM: antra (home)']] as const) { + map.set(destinationKey({ serverId, channelId: `dm-${serverId}` }), { + serverId, descriptor: { id: `dm-${serverId}`, type: 'chat', label, metadata: { channelType: 'dm', recipientName: 'antra' } }, open: true, + }); + } + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>@antra hello' }])); + let starts = 0; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts >= 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + } + }); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + const bounce = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text))[0] ?? ''; + assert.ok(bounce.includes('DM: antra (work)'), bounce); + assert.ok(bounce.includes('">>#channel {{unsent}}"'), 'no candidate is one token, so the example is generic'); + }); + + test(`a >> delivery that fails names the channel it was for apart, in its bounce (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'explicit', 'b'); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>b:general hello' }])); + let starts = 0; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts >= 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + } + }); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + const bounces = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + assert.ok(bounces.some((b) => b.includes('delivery to b:general failed')), bounces.join('\n')); + }); + + test(`a >>> delivery that fails names the channel it was for apart, in its bounce (${registered})`, async (t) => { + const f = await frameworkWith(t, order, 'hybrid', 'b'); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>>b:general hello' }])); + let starts = 0; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts >= 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>>skip_reply' }])); + } + }); + f.framework.pushEvent(incomingOn('a', 'hello from a')); + await f.framework.runUntilIdle(); + const bounces = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + assert.ok(bounces.some((b) => b.includes('delivery to b:general failed')), bounces.join('\n')); + }); + + test(`a wake naming a shared id without its server gives the turn no channel, and takes no fallback (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + // The last delivered message is on a's general, so a fallback would go there. + Object.assign(f.i.channelRegistry as unknown as { defaultPublishChannel: ChannelRef; defaultPublishMessageId: string }, + { defaultPublishChannel: a, defaultPublishMessageId: 'm-latest' }); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'for which general?' }])); + f.i.pendingRequests.push({ agentName: 'scout', reason: 'host:nudge', source: 'host', timestamp: Date.now(), channelId: 'general' }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1, 'the wake ran one turn'); + assert.equal(f.i.turnRouting.get('scout')?.triggerChannel, undefined); + assert.equal(f.i.turnRouting.get('scout')?.locus, undefined); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], 'nothing is published on either server'); + // Nor is the fallback what the registry resolves for the turn, or what + // the fallback's server is told the turn's outgoing channel is. + assert.equal(f.i.channelRegistry.resolveLocus('scout'), null); + assert.equal(f.i.channelRegistry.buildChannelContext('scout', 'a')?.defaultOutgoing, undefined); + // Nor is it replying to the latest inbound message, wherever that was. + assert.equal(f.i.channelRegistry.buildChannelContext('scout', 'a')?.incoming, undefined); + assert.equal(f.i.channelRegistry.incomingFor('scout'), undefined); + }); +} + +test("a bounce's example skips a candidate written skip_reply, which a >> line reads as private speech", async (t) => { + const f = await frameworkWith(t, ['a', 'b'], 'explicit'); + // Two channels labelled #lounge: a's has the id skip_reply, b's lounge-2. + const map = (f.i.channelRegistry as unknown as { channels: Map }).channels; + for (const [serverId, channelId] of [['a', 'skip_reply'], ['b', 'lounge-2']] as const) { + map.set(destinationKey({ serverId, channelId }), { serverId, descriptor: { id: channelId, type: 'chat', label: '#lounge' }, open: true }); + } + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>#lounge hello' }])); + let starts = 0; + f.framework.onTrace((e) => { + if ((e as { type: string }).type === 'inference:started' && ++starts >= 2) { + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + } + }); + f.framework.pushEvent(incomingOn('b', 'hello from b')); + await f.framework.runUntilIdle(); + const bounce = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text))[0] ?? ''; + assert.ok(bounce.includes('Known channels it could mean: skip_reply, lounge-2.'), bounce); + assert.ok(bounce.includes('e.g. ">>lounge-2 {{unsent}}"'), bounce); +}); + +test("explicit mode's typing reads each start's own wake, and a turn that has no channel reads only a wake that gives its server", async (t) => { + const f = await frameworkWith(t, ['a', 'b'], 'explicit'); + const typingFor = (routing: Record, trigger: Partial | undefined) => + (f.framework as unknown as { explicitTypingChannel(r: unknown, t: unknown): ChannelRef | null }).explicitTypingChannel(routing, trigger); + // The start's own trigger, with its server, whatever the turn's trigger channel became. + assert.deepEqual(typingFor({ triggerChannel: a }, { channelId: 'general', channelServerId: 'b' }), b); + assert.equal(typingFor({ triggerChannel: a }, { reason: 'context_budget_restart' }), null, 'a start whose trigger names no channel, in a turn that has shown none'); + assert.deepEqual(typingFor({ typing: b }, { reason: 'context_budget_restart' }), b, 'keeps the channel the turn already shows'); + assert.deepEqual(typingFor({ typing: b }, { channelId: 'general', channelServerId: 'a' }), a, + "a start's own wake comes before the channel the turn shows"); + // A turn that has no channel doesn't read a wake that gives no server, + // though its wake's name now means one channel (b's general went + // meanwhile); it keeps what the turn shows, here none. + (f.i.channelRegistry as unknown as { channels: Map }).channels.delete(destinationKey(b)); + assert.deepEqual(typingFor({}, { channelId: 'general' }), a, "control: the name means a's general now"); + assert.equal(typingFor({ noChannel: true }, { channelId: 'general' }), null); + // A later start's wake that gives its server, as the framework's own do, names that pair. + assert.deepEqual(typingFor({ noChannel: true }, { channelId: 'general', channelServerId: 'a' }), a); +}); + +test("explicit mode's typing is read once as a start opens, so registrations that move during its hooks don't move it", async (t) => { + const f = await frameworkWith(t, ['a', 'b'], 'explicit'); + f.fanOutToServers(); + const map = (f.i.channelRegistry as unknown as { channels: Map }).channels; + // Only a registers general as the start opens, so the host's bare id means a's. + const bEntry = map.get(destinationKey(b)); + map.delete(destinationKey(b)); + // While the start's hooks run, a's general goes and b's comes. + let moved = false; + for (const id of ['a', 'b']) { + const server = f.i.mcplServerRegistry.getServer(id) as unknown as { sendBeforeInference(p: unknown): Promise }; + const told = server.sendBeforeInference.bind(server); + server.sendBeforeInference = async (p) => { + if (!moved) { moved = true; map.delete(destinationKey(a)); map.set(destinationKey(b), bEntry); } + return told(p); + }; + } + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + f.i.pendingRequests.push({ agentName: 'scout', reason: 'host:nudge', source: 'host', timestamp: Date.now(), channelId: 'general' }); + await f.framework.runUntilIdle(); + assert.ok(moved, "control: the registrations moved during the start's hooks"); + assert.ok(f.calls.includes('a typing general'), `the start showed typing in a's general: ${f.calls.join('\n')}`); + assert.ok(!f.calls.includes('b typing general'), `nor did its stream move it to b's: ${f.calls.join('\n')}`); +}); + +test("a push's Discord channel, composed from its origin, is registered for the server that sent it, and its reply goes back there", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const map = (f.i.channelRegistry as unknown as { channels: Map }).channels; + // b holds the composed id already, labelled as its own. + map.set(destinationKey({ serverId: 'b', channelId: 'discord:g1:c1' }), { + serverId: 'b', descriptor: { id: 'discord:g1:c1', type: 'chat', label: "b's channel" }, open: true, + }); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the reply' }])); + (f.framework as unknown as { handleMcplPushEvent(e: unknown): unknown }).handleMcplPushEvent({ + type: 'mcpl:push-event', serverId: 'a', featureSet: 'discord', eventId: 'e1', inferenceId: 'i1', + timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'pushed from a' }], + origin: { source: 'discord', guildId: 'g1', channelId: 'c1', channelName: 'general (a)' }, + tags: ['chat:addressed'], triggerInference: true, + }); + assert.equal(f.i.channelRegistry.getDescriptor({ serverId: 'a', channelId: 'discord:g1:c1' })?.label, 'general (a)', + 'registered for a, which sent the push'); + assert.equal(f.i.channelRegistry.getDescriptor({ serverId: 'b', channelId: 'discord:g1:c1' })?.label, "b's channel", "b's is untouched"); + await f.framework.runUntilIdle(); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), ['a publish discord:g1:c1: the reply'], 'the reply goes back through a'); + assert.ok(!f.calls.some((c) => c.startsWith('b ')), 'b is sent nothing'); +}); + +test("a message moves the turn's locus to its own server's channel, by the server its metadata carries", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const repin = (m: Record, routing: { engaged: Set }) => + (f.framework as unknown as { repinChannel(m: unknown, r: unknown): ChannelRef | undefined }).repinChannel(m, routing); + const routing = { engaged: new Set([destinationKey(b)]), wasIn: [] as ChannelRef[] }; + assert.deepEqual(repin({ channelId: 'general', serverId: 'a', tags: ['chat:addressed'] }, routing), a, 'an addressed message'); + assert.deepEqual(repin({ channelId: 'general', serverId: 'b' }, routing), b, 'a follow-up where the turn sent'); + assert.equal(repin({ channelId: 'general', serverId: 'a' }, routing), undefined, "a's channel wasn't sent into, though it shares b's id"); + assert.equal(repin({ channelId: 'general', tags: ['chat:addressed'] }, routing), undefined, 'no server, and two channels with that id'); +}); + +test("a gate wake's route goes to a fork only where it's the fork's own home, on its server", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + conversationAgentHomes: Map; + mayTakeGateRoute(agentName: string, route: ChannelRef): boolean; + }; + fw.conversationAgentHomes.set('fork-a', a); + t.after(() => { fw.conversationAgentHomes.delete('fork-a'); }); + assert.equal(fw.mayTakeGateRoute('fork-a', a), true, "the fork's home"); + assert.equal(fw.mayTakeGateRoute('fork-a', b), false, "b's general is another channel, though it shares the id"); + assert.equal(fw.mayTakeGateRoute('scout', a), false, "a's general is the fork's conversation"); + assert.equal(fw.mayTakeGateRoute('scout', b), true, "b's general isn't bound to a fork"); +}); + +test("what an agent is told of a destination or a channel names it apart where its id is shared", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + destinationText(d: ChannelRef & { label?: string }): string; + conversationAgentHomes: Map; + recordReplyOpenFailedNotice(agent: string, serverId: string, channel: { channelId: string; label?: string }, selected?: 'subscription-policy'): void; + }; + assert.equal(fw.destinationText({ ...b, label: '#general' }), '#general (b:general)'); + // A fork's channel_close is refused for any channel but its home: a + // notice offers it only for that one, on its server. + fw.conversationAgentHomes.set('scout', a); + t.after(() => { fw.conversationAgentHomes.delete('scout'); }); + fw.recordReplyOpenFailedNotice('scout', b.serverId, { channelId: b.channelId, label: '#general' }, 'subscription-policy'); + fw.recordReplyOpenFailedNotice('scout', a.serverId, { channelId: a.channelId, label: '#general' }, 'subscription-policy'); + const notices = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'channel-notice') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + assert.equal(notices.length, 2, notices.join('\n')); + assert.ok(notices[0]!.includes('#general (b:general)') && !notices[0]!.includes('channel_close'), notices[0]); + assert.ok(notices[1]!.includes('#general (a:general)') && notices[1]!.includes('channel_close withdraws that'), notices[1]); +}); + +test("a resend to a shared id lists its candidates in registration order, and one named alone that takes no posts is refused before anything is sent", async (t) => { + const f = await frameworkWith(t, ['b', 'a']); + const fw = f.framework as unknown as { + proseDrafts: { hold(agent: string, segments: Array<{ text: string }>, reason: 'bounced'): { held: Array<{ id: string }> } }; + handleDraftsTool(agent: string, input: unknown): Promise; + }; + (f.i.channelRegistry as unknown as { channels: Map }).channels + .get(destinationKey(b))!.descriptor.direction = 'inbound'; + const [draft] = fw.proseDrafts.hold('scout', [{ text: 'held words' }], 'bounced').held; + const refused = ((await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [draft!.id], destination: 'general' })) as + { error?: string }).error ?? ''; + assert.ok(refused.includes('It could mean: b:general, a:general.'), refused); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], 'nothing was sent'); + // Named alone, it's refused at its destination, as a >> target is, before anything is sent. + const alone = ((await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [draft!.id], destination: 'b:general' })) as + { error?: string }).error ?? ''; + assert.match(alone, /^Destination "b:general" did not resolve: b:general is .+ Nothing was sent\.$/s); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], 'nothing was sent'); +}); + +test("a fork's resend goes home on its server, and another server's channel with that id is refused, both named apart", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + conversationAgentHomes: Map; + proseDrafts: { hold(agent: string, segments: Array<{ text: string }>, reason: 'bounced'): { held: Array<{ id: string }> } }; + handleDraftsTool(agent: string, input: unknown): Promise; + }; + fw.conversationAgentHomes.set('scout', b); + t.after(() => { fw.conversationAgentHomes.delete('scout'); }); + const [draft] = fw.proseDrafts.hold('scout', [{ text: 'held words' }], 'bounced').held; + const refused = ((await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [draft!.id], destination: 'a:general' })) as + { error?: string }).error ?? ''; + assert.ok(refused.includes('This conversation is bound to channel "b:general"; resending to "a:general" is not allowed.'), refused); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], 'nothing was sent'); + await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [draft!.id] }); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), ['b publish general: held words']); + // The shared id its home answers to means its home, as in its channel tools. + const [again] = fw.proseDrafts.hold('scout', [{ text: 'more words' }], 'bounced').held; + await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [again!.id], destination: 'general' }); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')).at(-1), 'b publish general: more words'); + // So does its qualified name, though another server's channel has that as its id. + (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'x', channelId: 'b:general' }), { + serverId: 'x', descriptor: { id: 'b:general', type: 'chat', label: "x's b:general" }, open: true, + }); + const [third] = fw.proseDrafts.hold('scout', [{ text: 'by its full name' }], 'bounced').held; + await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [third!.id], destination: 'b:general' }); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')).at(-1), 'b publish general: by its full name'); +}); + +test("the gate's batched summary names a channel as the registry shows it, through the framework's wiring", async (t) => { + const f = await frameworkWith(t, ['a', 'b'], undefined, undefined, true); + const gate = (f.framework as unknown as { eventGate: { channelShownFn?: (c: ChannelRef) => string } | null }).eventGate; + assert.ok(gate, 'the framework built its gate'); + assert.equal(gate!.channelShownFn?.(b), 'b:general'); + assert.equal(gate!.channelShownFn?.({ serverId: 'a', channelId: 'elsewhere' }), 'elsewhere'); +}); + +test("two servers' wakes on one channel id at one moment are two wakes to a provider hold's dedup", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const same = (f.framework as unknown as { sameInferenceRequest(x: InferenceRequest, y: InferenceRequest): boolean }) + .sameInferenceRequest.bind(f.framework); + const wake = (channelServerId: string): InferenceRequest => + ({ agentName: 'scout', reason: 'mention', source: 'mcpl', timestamp: 1, channelId: 'general', channelServerId }); + assert.equal(same(wake('a'), wake('a')), true); + assert.equal(same(wake('a'), wake('b')), false, "b's wake isn't a's"); +}); + +/** Wait for an event the test is about, failing after a deadline rather than passing on a fixed sleep. */ +async function until(condition: () => boolean, what: string, ms = 60_000): Promise { + const deadline = Date.now() + ms; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +type ToolResult = { success: boolean; data?: unknown; error?: string }; + +/** The tool results the framework pushes, by call id, kept from its queue. */ +function toolResults(t: TestContext, framework: AgentFramework): Map { + const results = new Map(); + const push = framework.pushEvent.bind(framework); + t.mock.method(framework, 'pushEvent', (event: ProcessEvent) => { + const e = event as unknown as { type: string; callId: string; result: ToolResult }; + if (e.type === 'tool-result') results.set(e.callId, e.result); + else push(event); + }); + return results; +} + +type Dispatcher = (agentName: string, call: { id: string; name: string; input: Record }) => void; + +for (const order of orders) { + const registered = `registered ${order.join(' then ')}`; + + test(`a fork homed on b's general publishes there by the bare id, and a's channel is foreign to it (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', b); + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); + + // A fork acts only on its home, so the id its home answers to means its home. + dispatch('scout', { id: 'home', name: 'channel_publish', input: { channelId: 'general', content: 'to my home' } }); + await until(() => results.has('home'), 'the home publish'); + assert.equal(results.get('home')?.success, true); + dispatch('scout', { id: 'qualified', name: 'channel_publish', input: { channelId: 'b:general', content: 'by its full name' } }); + await until(() => results.has('qualified'), 'the qualified publish'); + dispatch('scout', { id: 'id-server', name: 'channel_publish', input: { channelId: 'general', serverId: 'b', content: 'and its server' } }); + await until(() => results.has('id-server'), 'the publish by its id and server'); + // A serverId beside a channelId names that exact pair, which a qualified + // name with its own server isn't, and the fork is told the name works alone. + dispatch('scout', { id: 'qualified-server', name: 'channel_publish', input: { channelId: 'b:general', serverId: 'b', content: 'not this' } }); + assert.equal(results.get('qualified-server')?.error, + '"b:general" names this conversation\'s channel by itself, so nothing was posted: call again with serverId left out.'); + dispatch('scout', { id: 'close-qualified-server', name: 'channel_close', input: { channelId: 'b:general', serverId: 'b' } }); + assert.equal(results.get('close-qualified-server')?.error, + '"b:general" names this conversation\'s channel by itself, so nothing was closed: call again with serverId left out.'); + // Beside another server's id it names that server's channel, foreign to the fork. + dispatch('scout', { id: 'qualified-a', name: 'channel_publish', input: { channelId: 'b:general', serverId: 'a', content: 'not this' } }); + assert.equal(results.get('qualified-a')?.error, + 'This conversation is bound to channel "b:general"; publishing to "b:general" on server "a" is not allowed.'); + // The home's own serverId alone names the home. + dispatch('scout', { id: 'server-b', name: 'channel_publish', input: { serverId: 'b', content: 'by its server' } }); + await until(() => results.has('server-b'), 'the publish by its server alone'); + // An empty serverId is none given, as the channel tools read it. + dispatch('scout', { id: 'empty-server', name: 'channel_publish', input: { channelId: 'general', serverId: '', content: 'empty server' } }); + await until(() => results.has('empty-server'), 'the publish with an empty serverId'); + assert.equal(results.get('empty-server')?.success, true, results.get('empty-server')?.error); + dispatch('scout', { id: 'empty-alone', name: 'channel_publish', input: { serverId: '', content: 'empty server alone' } }); + await until(() => results.has('empty-alone'), 'the publish with only an empty serverId'); + // No channelId at all means the home, not the registry's default channel. + dispatch('scout', { id: 'no-channel', name: 'channel_publish', input: { content: 'no channel given' } }); + await until(() => results.has('no-channel'), 'the publish with no channelId'); + dispatch('scout', { id: 'foreign-empty', name: 'channel_publish', input: { channelId: 'a:general', serverId: '', content: 'elsewhere' } }); + assert.equal(results.get('foreign-empty')?.error, + 'This conversation is bound to channel "b:general"; publishing to "a:general" is not allowed.'); + // What was asked is quoted as an id is, escapes included. + dispatch('scout', { id: 'odd-server', name: 'channel_publish', input: { channelId: 'general', serverId: 'x"y', content: 'elsewhere' } }); + assert.equal(results.get('odd-server')?.error, + 'This conversation is bound to channel "b:general"; publishing to "general" on server "x\\"y" is not allowed.'); + + dispatch('scout', { id: 'foreign', name: 'channel_publish', input: { channelId: 'a:general', content: 'elsewhere' } }); + assert.equal(results.get('foreign')?.error, + 'This conversation is bound to channel "b:general"; publishing to "a:general" is not allowed.'); + // A serverId the call gives has to be the home's, with a channelId or without. + dispatch('scout', { id: 'server-only', name: 'channel_publish', input: { serverId: 'a', content: 'elsewhere' } }); + assert.equal(results.get('server-only')?.error, + 'This conversation is bound to channel "b:general"; publishing to a channel on server "a" is not allowed.'); + dispatch('scout', { id: 'id-on-a', name: 'channel_publish', input: { channelId: 'general', serverId: 'a', content: 'elsewhere' } }); + assert.equal(results.get('id-on-a')?.error, + 'This conversation is bound to channel "b:general"; publishing to "general" on server "a" is not allowed.'); + // Nor may it close the other server's channel with its home's id, as + // channel_close's serverId would pick it (anima-research/agent-framework#612). + dispatch('scout', { id: 'close-on-a', name: 'channel_close', input: { channelId: 'general', serverId: 'a' } }); + assert.equal(results.get('close-on-a')?.error, + 'This conversation is bound to channel "b:general"; closing "general" on server "a" is not allowed.'); + assert.notEqual(f.i.channelRegistry.getDesiredState('a', 'general'), 'closed', "no close of a's channel was decided"); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [ + 'b publish general: to my home', 'b publish general: by its full name', 'b publish general: and its server', + 'b publish general: by its server', 'b publish general: empty server', 'b publish general: empty server alone', + 'b publish general: no channel given', + ], 'nothing was posted on a'); + // Where the pair a call gives is another registered channel, one whose id + // is the home's qualified name, it's that channel, foreign to the fork. + (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'x', channelId: 'b:general' }), { + serverId: 'x', descriptor: { id: 'b:general', type: 'chat', label: "x's b:general" }, open: true, + }); + dispatch('scout', { id: 'x-pair', name: 'channel_publish', input: { channelId: 'b:general', serverId: 'x', content: 'to x' } }); + assert.equal(results.get('x-pair')?.error, + 'This conversation is bound to channel "general" on server "b"; publishing to "b:general" on server "x" is not allowed.'); + // With no serverId, a name its home answers to still means its home. + dispatch('scout', { id: 'qualified-beside-x', name: 'channel_publish', input: { channelId: 'b:general', content: 'home again' } }); + await until(() => results.has('qualified-beside-x'), 'the qualified publish beside x'); + assert.equal(results.get('qualified-beside-x')?.success, true); + assert.ok(f.calls.includes('b publish general: home again'), f.calls.join('\n')); + // Where the home's own server registers a channel whose id is the home's + // qualified name, the pair is that channel, foreign to the fork, and + // nothing tells the fork to leave the serverId out. + (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey({ serverId: 'b', channelId: 'b:general' }), { + serverId: 'b', descriptor: { id: 'b:general', type: 'chat', label: "b's b:general" }, open: true, + }); + dispatch('scout', { id: 'b-pair', name: 'channel_publish', input: { channelId: 'b:general', serverId: 'b', content: 'to b:general' } }); + assert.equal(results.get('b-pair')?.error, + 'This conversation is bound to channel "general" on server "b"; publishing to "b:general" on server "b" is not allowed.'); + // A home no name picks now is quoted as its id and server. + dispatch('scout', { id: 'open', name: 'channel_open', input: { channelId: 'elsewhere' } }); + assert.equal(results.get('open')?.error, 'This conversation is bound to channel "general" on server "b", so it can\'t open ' + + 'other channels, and nothing was opened. Reply in "general" on server "b".'); + }); + + test(`tune_out refuses a shared id with its candidates, and takes one server's channel by its qualified name (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + const entered: string[] = []; + (f.framework as unknown as { tuneOutCoordinator: unknown }).tuneOutCoordinator = { + enter: (serverId: string, channelId: string) => { + entered.push(`${serverId} ${channelId}`); + return { ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }; + }, + cancel: (serverId: string, channelId: string) => { entered.push(`cancel ${serverId} ${channelId}`); return { ok: true }; }, + stop: () => {}, + }; + // The tune-out's transport is another test's (tune-out-transport): here + // it opens at once, so the answer names only the channel and settings. + const opened: string[] = []; + (f.i.channelRegistry as unknown as { openForTuneOut(serverId: string, channelId: string): Promise }) + .openForTuneOut = async (serverId: string, channelId: string) => { opened.push(`${serverId} ${channelId}`); return undefined; }; + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchTuneOutToolCall: Dispatcher }).dispatchTuneOutToolCall.bind(f.framework); + + dispatch('scout', { id: 'shared', name: 'tune_out', input: { channelId: 'general' } }); + assert.equal(results.get('shared')?.error, + `Channel "general" is on more than one server (${order.map((s) => `"${s}:general"`).join(', ')}), so nothing ran. ` + + 'Call again with channelId set to one of them.'); + dispatch('scout', { id: 'one', name: 'tune_out', input: { channelId: 'b:general' } }); + // A tune-out's answer waits for its transport (openForTuneOut). + for (let i = 0; i < 20 && !results.has('one'); i++) await new Promise((r) => setImmediate(r)); + assert.deepEqual(results.get('one')?.data, { tunedOut: 'b:general', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 }, + 'the result names the channel as the agent can name it back'); + dispatch('scout', { id: 'cancel', name: 'tune_out', input: { channelId: 'b:general', mode: 'cancel' } }); + assert.deepEqual(results.get('cancel')?.data, { cancelled: 'b:general' }); + assert.deepEqual(entered, ['b general', 'cancel b general']); + assert.deepEqual(opened, ['b general'], "the tune-out's transport is opened on b's channel"); + }); + + test(`tune_out inside the turn reads the id of the turn's own channel as that channel, and refuses it once its server withdrew it (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + const entered: string[] = []; + (f.framework as unknown as { tuneOutCoordinator: unknown }).tuneOutCoordinator = { + enter: (serverId: string, channelId: string) => { + entered.push(`${serverId} ${channelId}`); + return { ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }; + }, + cancel: () => ({ ok: true }), + stop: () => {}, + }; + (f.i.channelRegistry as unknown as { openForTuneOut(serverId: string, channelId: string): Promise }) + .openForTuneOut = async () => undefined; + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchTuneOutToolCall: Dispatcher }).dispatchTuneOutToolCall.bind(f.framework); + // The turn answers a message in b's general. + (f.framework as unknown as { turnRouting: Map }).turnRouting.set('scout', { + turnId: 9, wasIn: [{ serverId: 'b', channelId: 'general' }], triggerChannel: { serverId: 'b', channelId: 'general' }, engaged: new Set(), + }); + dispatch('scout', { id: 'own', name: 'tune_out', input: { channelId: 'general' }, callerTurnId: 9 } as never); + for (let i = 0; i < 20 && !results.has('own'); i++) await new Promise((r) => setImmediate(r)); + assert.deepEqual(results.get('own')?.data, { tunedOut: 'b:general', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 }, + "the turn's own channel, not a shared-id refusal"); + await (f.i.channelRegistry as unknown as ChannelRegistry).handleChanged('b', { removed: ['general'] }); + dispatch('scout', { id: 'gone', name: 'tune_out', input: { channelId: 'general' }, callerTurnId: 9 } as never); + assert.equal(results.get('gone')?.error, '"general" is this turn\'s channel on server "b", which no longer registers it, so nothing ran, ' + + 'and nothing can run on it until that server registers it again.'); + assert.deepEqual(entered, ['b general'], "a's general isn't taken for it"); + }); + + test(`tune_out's answer names the channel as it's named once its transport opened, though another server registered its id meanwhile (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + const reg = f.i.channelRegistry as unknown as ChannelRegistry & { openForTuneOut(serverId: string, channelId: string): Promise }; + await reg.handleRegister('a', { channels: [{ id: 'solo', type: 'chat', label: 'solo', direction: 'bidirectional' }] } as never); + (f.framework as unknown as { tuneOutCoordinator: unknown }).tuneOutCoordinator = { + enter: () => ({ ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }), + cancel: () => ({ ok: true }), + stop: () => {}, + }; + reg.openForTuneOut = async () => { + await reg.handleRegister('b', { channels: [{ id: 'solo', type: 'chat', label: 'solo', direction: 'bidirectional' }] } as never); + return undefined; + }; + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchTuneOutToolCall: Dispatcher }).dispatchTuneOutToolCall.bind(f.framework); + dispatch('scout', { id: 'solo', name: 'tune_out', input: { channelId: 'solo' } }); + for (let i = 0; i < 20 && !results.has('solo'); i++) await new Promise((r) => setImmediate(r)); + assert.equal((results.get('solo')?.data as { tunedOut?: string } | undefined)?.tunedOut, 'a:solo'); + }); + + test(`tune_out repeated while its transport is closed answers once it opens, naming the channel as it's named then (${registered})`, async (t) => { + const f = await frameworkWith(t, order); + const reg = f.i.channelRegistry as unknown as ChannelRegistry & { + openForTuneOut(serverId: string, channelId: string): Promise; + getTuneOutState(serverId: string, channelId: string): unknown; + tuneOutTransportOpen(serverId: string, channelId: string): boolean; + }; + await reg.handleRegister('a', { channels: [{ id: 'solo', type: 'chat', label: 'solo', direction: 'bidirectional' }] } as never); + const params = { epochId: 'e1', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }; + (f.framework as unknown as { tuneOutCoordinator: unknown }).tuneOutCoordinator = { + enter: () => ({ ok: false, error: 'already', alreadyTunedOut: true }), + cancel: () => ({ ok: true }), + stop: () => {}, + }; + reg.getTuneOutState = () => ({ params, wakeCount: 0 }); + reg.tuneOutTransportOpen = () => false; + reg.openForTuneOut = async () => { + await reg.handleRegister('b', { channels: [{ id: 'solo', type: 'chat', label: 'solo', direction: 'bidirectional' }] } as never); + return undefined; + }; + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchTuneOutToolCall: Dispatcher }).dispatchTuneOutToolCall.bind(f.framework); + dispatch('scout', { id: 'again', name: 'tune_out', input: { channelId: 'solo' } }); + for (let i = 0; i < 20 && !results.has('again'); i++) await new Promise((r) => setImmediate(r)); + assert.match(results.get('again')?.error ?? '', /^Nothing changed in the tune-out: "a:solo" is already tuned out, /); + }); + + test(`tune_out repeated on one server's channel of a shared id says it's already tuned out, naming it as the agent names it back (${registered})`, async () => { + const f = fixture(both(order)); + const params = { epochId: 'e1', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }; + // The registry is the fixture's real one, with b's general tuned out. + (f.registry as unknown as { getTuneOutState(s: string, c: string): unknown }).getTuneOutState = + (s: string, c: string) => (s === 'b' && c === 'general' ? { params, wakeCount: 0 } : undefined); + const coordinator = new TuneOutCoordinator(f.registry as never, {} as never, { + subconsciousName: () => 'Subconscious', isForkBound: () => false, emitTrace: () => {}, + } as never); + const again = coordinator.enter('b', 'general', {}, 'agent-tool'); + assert.match((again as { error?: string }).error ?? '', /^Nothing changed: "b:general" is already tuned out, /); + }); +} + +test("a fork whose home its server no longer registers is told so, and not offered another server's channel", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', b); + const results = toolResults(t, f.framework); + const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); + // b's general goes; a's general keeps the id. + (f.i.channelRegistry as unknown as { channels: Map }).channels.delete(destinationKey(b)); + dispatch('scout', { id: 'publish', name: 'channel_publish', input: { content: 'to a gone home' } }); + assert.equal(results.get('publish')?.error, + 'This conversation is bound to channel "b:general", which isn\'t registered now, so nothing was posted, ' + + 'and nothing can be until its server registers it again.'); + dispatch('scout', { id: 'close', name: 'channel_close', input: { channelId: 'general' } }); + assert.equal(results.get('close')?.error, + 'This conversation is bound to channel "b:general", which isn\'t registered now, so nothing was closed, ' + + 'and nothing can be until its server registers it again.'); + // So is a call that names it with its server, or by its server alone. + for (const [id, input] of [['id-server', { channelId: 'general', serverId: 'b' }], ['server-only', { serverId: 'b' }]] as const) { + dispatch('scout', { id, name: 'channel_publish', input: { ...input, content: 'x' } }); + assert.equal(results.get(id)?.error, + 'This conversation is bound to channel "b:general", which isn\'t registered now, so nothing was posted, ' + + 'and nothing can be until its server registers it again.', id); + } + // Before any advice to call again, and with no reply offered in it. + dispatch('scout', { id: 'qualified-server', name: 'channel_publish', input: { channelId: 'b:general', serverId: 'b', content: 'x' } }); + assert.equal(results.get('qualified-server')?.error, + 'This conversation is bound to channel "b:general", which isn\'t registered now, so nothing was posted, ' + + 'and nothing can be until its server registers it again.'); + dispatch('scout', { id: 'open', name: 'channel_open', input: { channelId: 'elsewhere' } }); + assert.equal(results.get('open')?.error, + 'This conversation is bound to channel "b:general", which isn\'t registered now; it can\'t open other channels, and nothing was opened.'); + assert.deepEqual(f.calls.filter((c) => c.includes('publish')), [], "nothing was posted to a's general, which has the home's id"); +}); + +test("coalesced channel work is admitted only while its own server registers the channel, not another server's channel with the same id", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + coalescingBinding(serverId: string): string; + coalescedAuthorized(occ: unknown): boolean; + }; + const work = (serverId: string) => ({ + serverId, + binding: fw.coalescingBinding(serverId), + scope: { kind: 'channel', id: 'general' }, + event: { lane: 'channel' }, + }); + assert.equal(fw.coalescedAuthorized(work('a')), true, "control: a's channel is registered"); + // a's general goes; b's stays registered under the same id. + (f.i.channelRegistry as unknown as { channels: Map }).channels.delete(destinationKey(a)); + assert.equal(fw.coalescedAuthorized(work('a')), false, "b's channel carries none of a's authority"); + assert.equal(fw.coalescedAuthorized(work('b')), true); +}); + +test("a [channels] notice names a channel whose id another server shares by its qualified id, which channel_close takes", async (t) => { + const f = await frameworkWith(t, ['a', 'b']); + const fw = f.framework as unknown as { + recordChannelAutoOpenNotice(agent: string | undefined, serverId: string, channels: Array<{ channelId: string; label?: string }>, cause: 'opened-by-reply'): void; + recordReplyOpenFailedNotice(agent: string | undefined, serverId: string, channel: { channelId: string; label?: string }): void; + }; + fw.recordChannelAutoOpenNotice('scout', b.serverId, [{ channelId: b.channelId, label: '#general' }], 'opened-by-reply'); + fw.recordReplyOpenFailedNotice('scout', b.serverId, { channelId: b.channelId, label: '#general' }); + const notices = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'channel-notice') + .flatMap((m) => m.content.filter((c): c is { type: 'text'; text: string } => c.type === 'text').map((c) => c.text)); + assert.equal(notices.length, 2, notices.join('\n')); + assert.ok(notices.every((n) => n.includes('#general (b:general)')), notices.join('\n')); +}); diff --git a/test/silent-heartbeat-rows.test.ts b/test/silent-heartbeat-rows.test.ts index 28be2aee3..4f9a41990 100644 --- a/test/silent-heartbeat-rows.test.ts +++ b/test/silent-heartbeat-rows.test.ts @@ -162,8 +162,8 @@ async function make(opts: { modules: [mod], }); (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', - routeSpeech: async () => ({ delivered: opts.deliver ?? true, channelId: 'world:commons' }), + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:commons' }), getDefaultPublishChannel: () => ({ serverId: 'stub', channelId: 'world:commons' }), + routeSpeech: async () => ({ delivered: opts.deliver ?? true, serverId: 'stub', channelId: 'world:commons' }), startTyping: () => {}, stopTyping: () => {}, getChannelTools: () => [], getDescriptor: () => undefined, }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); diff --git a/test/silent-heartbeat.test.ts b/test/silent-heartbeat.test.ts index eae5301ef..3ce59ad39 100644 --- a/test/silent-heartbeat.test.ts +++ b/test/silent-heartbeat.test.ts @@ -45,9 +45,9 @@ async function make() { const routed: string[] = []; const typing: string[] = []; (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', - routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, - startTyping: (channel: string) => { typing.push(channel); }, + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:commons' }), getDefaultPublishChannel: () => ({ serverId: 'stub', channelId: 'world:commons' }), + routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:commons' }; }, + startTyping: (channel: { channelId: string }) => { typing.push(channel.channelId); }, stopTyping: () => {}, getChannelTools: () => [], getDescriptor: () => undefined, }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); diff --git a/test/skip-reply-wake-capability.test.ts b/test/skip-reply-wake-capability.test.ts index 8f3048642..967d8a519 100644 --- a/test/skip-reply-wake-capability.test.ts +++ b/test/skip-reply-wake-capability.test.ts @@ -38,10 +38,10 @@ describe('skip_reply wake capability', () => { const routed: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ getChannelTools: () => [], - resolveLocus: () => 'channel:test', + resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), routeSpeech: async (_agent: string, text: string) => { routed.push(text); - return { delivered: true, channelId: 'channel:test' }; + return { delivered: true, serverId: 'stub', channelId: 'channel:test' }; }, handleChannelToolCall: async (_name: string, input: { wake_in_seconds?: number }) => ({ success: true, endTurn: true, @@ -124,8 +124,8 @@ describe('skip_reply wake capability', () => { agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }], modules: [], }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - getChannelTools: () => [], resolveLocus: () => 'channel:test', - routeSpeech: async () => ({ delivered: true, channelId: 'channel:test' }), + getChannelTools: () => [], resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), + routeSpeech: async () => ({ delivered: true, serverId: 'stub', channelId: 'channel:test' }), handleChannelToolCall: async () => ({ success: true, endTurn: true, data: { skipped: true } }), }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined }); const agent = framework.getAgent('agent')!; @@ -153,8 +153,8 @@ describe('skip_reply wake capability', () => { }); const routed: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - getChannelTools: () => [], resolveLocus: () => 'channel:test', - routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; }, + getChannelTools: () => [], resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'channel:test' }; }, handleChannelToolCall: async () => ({ success: true, data: { delivered: true } }), }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined }); const agent = framework.getAgent('agent')!; @@ -188,8 +188,8 @@ describe('skip_reply wake capability', () => { }); const routed: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - getChannelTools: () => [], resolveLocus: () => 'channel:test', - routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; }, + getChannelTools: () => [], resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'channel:test' }; }, handleChannelToolCall: async () => ({ success: true, data: { delivered: true } }), }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined }); const agent = framework.getAgent('agent')!; @@ -293,8 +293,8 @@ describe('refused wake: no lifecycle events; a wake_in_seconds the host can\'t s const routed: string[] = []; const handled: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - getChannelTools: () => [], resolveLocus: () => 'channel:test', - routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; }, + getChannelTools: () => [], resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'channel:test' }; }, // What reached the registry: a skip_reply ends the turn, and a send // silences the rest of it. handleChannelToolCall: async (toolName: string) => { diff --git a/test/skip-reply-wake-xml-mode.test.ts b/test/skip-reply-wake-xml-mode.test.ts index d5eb7ebd1..4d733770e 100644 --- a/test/skip-reply-wake-xml-mode.test.ts +++ b/test/skip-reply-wake-xml-mode.test.ts @@ -152,8 +152,8 @@ async function xmlTurn(rounds: Round[], finalText: string, thinking = false, opt ? [{ name: 'skip_reply', description: 'End your turn without sending anything.', inputSchema: { type: 'object' as const, properties: {} } }] : []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - getChannelTools: () => channelTools, resolveLocus: () => 'channel:test', - routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'channel:test' }; }, + getChannelTools: () => channelTools, resolveLocus: () => ({ serverId: 'stub', channelId: 'channel:test' }), + routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'channel:test' }; }, handleChannelToolCall: async (name: string) => { registrySaw.push(name); return { success: true, endTurn: true, data: { skipped: true, note: 'Turn ended; nothing sent.' } }; diff --git a/test/state-read-and-write-errors.test.ts b/test/state-read-and-write-errors.test.ts index 94610a57b..823e7c5cf 100644 --- a/test/state-read-and-write-errors.test.ts +++ b/test/state-read-and-write-errors.test.ts @@ -421,8 +421,10 @@ describe("the conversation router's generation counters", () => { f.alwaysRespond(); await f.framework.runUntilIdle(); assert.ok(failedWrites.count > 0, "the batch reached its fork's spawn and tried to store the counter"); - const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): unknown } }).conversationRouter; - assert.equal(router.getBinding('chat'), undefined, 'no fork was bound'); + const router = (f.framework as unknown as { + conversationRouter: { getBinding(channel: { serverId: string; channelId: string }): unknown }; + }).conversationRouter; + assert.equal(router.getBinding({ serverId: 'editor', channelId: 'chat' }), undefined, 'no fork was bound'); assert.equal(f.framework.getAgent(forkAgentName('conversation', 'editor', 'chat', 1)), null); assert.equal(store.getStateJson(ROUTER), null); } finally { diff --git a/test/stop-without-stream.test.ts b/test/stop-without-stream.test.ts index cf9c13ff5..0d1443cd6 100644 --- a/test/stop-without-stream.test.ts +++ b/test/stop-without-stream.test.ts @@ -1332,17 +1332,18 @@ class ScriptMembrane { function firstPostHeld(posted: string[], released: Promise): unknown { // Speech reaches the registry through deliverSpeech, or through // routeSpeech, which reduces its outcome to the confirmed delivery. - const deliverSpeech = async (_agent: string, text: string, channelId: string | null) => { + type Channel = { serverId: string; channelId: string }; + const deliverSpeech = async (_agent: string, text: string, to: Channel | null) => { posted.push(text.trim()); if (posted.length === 1) await released; - return { status: 'delivered' as const, destination: { serverId: 'stub', channelId: channelId ?? '' }, at: Date.now() }; + return { status: 'delivered' as const, destination: { serverId: to?.serverId ?? 'stub', channelId: to?.channelId ?? '' }, at: Date.now() }; }; return new Proxy({ - resolveProseTarget: (spec: string) => spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, + resolveProseTarget: (spec: string) => spec === '#cafe' ? { serverId: 'stub', channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, deliverSpeech, - routeSpeech: async (agent: string, text: string, channelId: string) => { - const outcome = await deliverSpeech(agent, text, channelId); - return { delivered: true, channelId: outcome.destination.channelId }; + routeSpeech: async (agent: string, text: string, to: Channel | null) => { + const outcome = await deliverSpeech(agent, text, to); + return { delivered: true, serverId: outcome.destination.serverId, channelId: outcome.destination.channelId }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index c9201aadf..46013fee7 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -9,6 +9,7 @@ import { MembraneError, Membrane as RealMembrane, NativeFormatter, type Provider type ProviderResponse, type StreamCallbacks } from '@animalabs/membrane'; import { AgentFramework, type AgentConfig, type AgentSettingsExtension, type Module, type ModuleContext, type ToolCall, type ToolResult, type ProcessEvent, type ProcessState } from '../src/index.js'; +import { destinationKey } from '../src/mcpl/channel-registry.js'; import { TOOL_RESULT_GUARD_AUDIT_STATE, TOOL_RESULT_GUARD_NOTICE, keptWhenWithheld, withheldResultNotice } from '../src/tool-result-guard.js'; import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; import { UNREACHED_WATCHDOG, silenceRun } from './helpers/run-watchdog.js'; @@ -133,15 +134,37 @@ function toolResults(framework: AgentFramework) { * deliverSpeech that explicit and hybrid delivery post through. */ function withDeliverSpeech>(stub: T): T { + // The framework names a channel with its server. These stubs work by + // channel id, and every channel is the stub server's, so this adapts + // between the two: a locus or target gets the stub's server, and delivery + // hands each stub the channel's id. + type Channel = { serverId: string; channelId: string }; + const idOf = (channel: Channel | string | null) => (channel !== null && typeof channel === 'object' ? channel.channelId : channel) as string; const route = stub.routeSpeech as (agent: string, text: string, channelId: string) => Promise<{ delivered?: boolean; channelId?: string } | undefined>; + const locus = stub.resolveLocus as ((agent: string) => string | null) | undefined; + const target = stub.resolveProseTarget as ((spec: string, opts?: unknown) => Record) | undefined; return Object.assign(stub, { - deliverSpeech: async (agent: string, text: string, channelId: string) => { + routeSpeech: async (agent: string, text: string, channel: Channel | null) => { + const result = await route(agent, text, idOf(channel)); + return result?.delivered ? { ...result, serverId: 'stub' } : result; + }, + deliverSpeech: async (agent: string, text: string, channel: Channel | null) => { + const channelId = idOf(channel); const result = await route(agent, text, channelId); return result?.delivered ? { status: 'delivered', destination: { serverId: 'stub', channelId: result.channelId ?? channelId }, at: Date.now() } : { status: 'failed', destination: { serverId: 'stub', channelId }, reason: 'the stub reported delivered: false', at: Date.now() }; }, resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), + ...(locus ? { resolveLocus: (agent: string) => { const id = locus(agent); return id ? { serverId: 'stub', channelId: id } : null; } } : {}), + ...(target ? { + resolveProseTarget: (spec: string, opts?: unknown) => { + const resolved = target(spec, opts); + return 'channelId' in resolved ? { serverId: 'stub', ...resolved } : resolved; + }, + } : {}), + channelsNamed: (name: string) => [{ serverId: 'stub', channelId: name }], + nameShown: (channel: Channel) => channel.channelId, }); } @@ -1047,13 +1070,15 @@ test("hybrid: a restarted turn whose last response is empty writes its receipt a test("a fresh turn starts new routing state in every mode, and leaves the earlier turn's as it was", async () => { for (const mode of ['explicit', 'hybrid', 'locus'] as const) { const h = await harness([[answer()]], { proseRouting: mode }); - type Routing = { pin?: string; hybridHold?: 'private' | 'failed'; - continuation: boolean; deliveries: string[]; suppressed: number; drafts: unknown[]; keptPrivate: number; - triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }> }; + type Channel = { serverId: string; channelId: string }; + type Routing = { pin?: Channel; hybridHold?: 'private' | 'failed'; + continuation: boolean; deliveries: Channel[]; suppressed: number; drafts: unknown[]; keptPrivate: number; + triggerChannel?: Channel; locus?: Channel; engaged: Set; wasIn: Channel[]; arrivals: Array<{ messageId: string; channel: Channel }> }; const internals = h.framework as unknown as { turnRouting: Map }; - const earlierFields = (): Routing => ({ pin: 'chan-earlier', hybridHold: 'failed', - continuation: true, deliveries: ['chan-earlier'], suppressed: 2, drafts: [{ id: 'd-earlier' }], keptPrivate: 1, - triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrivals: [{ messageId: 'earlier-message', channelId: 'chan-earlier' }] }); + const earlierChannel = { serverId: 'stub', channelId: 'chan-earlier' }; + const earlierFields = (): Routing => ({ pin: earlierChannel, hybridHold: 'failed', + continuation: true, deliveries: [earlierChannel], suppressed: 2, drafts: [{ id: 'd-earlier' }], keptPrivate: 1, + triggerChannel: earlierChannel, locus: earlierChannel, engaged: new Set([destinationKey(earlierChannel)]), wasIn: [earlierChannel], arrivals: [{ messageId: 'earlier-message', channel: earlierChannel }] }); const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); try { @@ -3121,9 +3146,9 @@ test("#281: a conversation fork seeded while its template's stub is written copi await framework.runUntilIdle().catch(() => {}); await until(() => spills === 1, "the template's aborted batch is being annotated"); const compiles = watchCompiles(framework); - const internals = framework as unknown as { createConversationAgent(name: string, channelId: string): Promise<{ + const internals = framework as unknown as { createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise<{ getContextManager(): { getAllMessages(): Array<{ content: ContentBlock[] }> } }> }; - const forking = internals.createConversationAgent('assistant-chan-g1', 'world:chan'); + const forking = internals.createConversationAgent('assistant-chan-g1', { serverId: 'stub', channelId: 'world:chan' }); await until(() => compiles.calls > 0, "the fork's seed waits for the template's stub"); write.open(); const fork = await forking; diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index 0ede8177a..0bb1b6119 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -154,9 +154,9 @@ async function run(enabled?: boolean, allowedTools?: string[]) { const routed: string[] = []; const outgoing: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', - routeSpeech: async (_agent: string, speech: string, channelId: string) => { - routed.push(speech); return { delivered: true, channelId: 'world:test' }; + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), + routeSpeech: async (_agent: string, speech: string, _to: { serverId: string; channelId: string }) => { + routed.push(speech); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], @@ -234,8 +234,8 @@ describe('tool wrapper prose guard integration', () => { agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [module], }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string, channelId: string) => { - routed.push(text); return { delivered: true, channelId: 'world:test' }; + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), routeSpeech: async (_a: string, text: string, _to: { serverId: string; channelId: string }) => { + routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_c: string, _a: string, _id: string, _i: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], @@ -254,7 +254,7 @@ describe('tool wrapper prose guard integration', () => { const module = new HeartbeatModule(); const membrane = new ToolThenFrameworkRetryMembrane(); const routed: string[] = []; const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -273,7 +273,7 @@ describe('tool wrapper prose guard integration', () => { ]); const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', maxStreamTokens: 1, toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -293,9 +293,9 @@ describe('tool wrapper prose guard integration', () => { agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [new HeartbeatModule()], }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', - routeSpeech: async (_agent: string, text: string, channelId: string) => { - routed.push(text); return { delivered: true, channelId: 'world:test' }; + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), + routeSpeech: async (_agent: string, text: string, _to: { serverId: string; channelId: string }) => { + routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], @@ -328,7 +328,7 @@ describe('tool wrapper prose guard integration', () => { modules: [module], }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); @@ -496,16 +496,16 @@ describe('tool wrapper prose guard integration', () => { const typing: string[] = []; const outgoing: Array<[string, string]> = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', - startTyping: (channel: string) => typing.push(`start:${channel}`), - stopTyping: (channel?: string) => typing.push(`stop:${channel ?? '(all)'}`), - routeSpeech: async (_agent: string, text: string, channelId: string) => { - return { delivered: true, channelId: 'world:test' }; + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), + startTyping: (channel: { channelId: string }) => typing.push(`start:${channel.channelId}`), + stopTyping: (channel?: { channelId: string }) => typing.push(`stop:${channel?.channelId ?? '(all)'}`), + routeSpeech: async (_agent: string, text: string, _to: { serverId: string; channelId: string }) => { + return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => outgoing.push(['chunk', delta]), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => outgoing.push(['complete', text]), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - resolveProseTarget: () => ({ channelId: 'world:test' }), + getDefaultPublishChannel: () => ({ serverId: 'stub', channelId: 'world:test' }), isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + resolveProseTarget: () => ({ serverId: 'stub', channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'ephemeral-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); created.contextManager.addMessage('user', [{ type: 'text', text: 'go' }]); @@ -532,16 +532,16 @@ describe('tool wrapper prose guard integration', () => { agents: [{ name: 'trunk', model: 'test', systemPrompt: 'sys' }], modules: [], conversations: { templateAgent: 'trunk', bind: { channel: 'mention' } }, }); - const internals = framework as unknown as { store: Record; createConversationAgent(name: string, channelId: string): Promise<{ name: string }> }; + const internals = framework as unknown as { store: Record; createConversationAgent(name: string, home: { serverId: string; channelId: string }): Promise<{ name: string }> }; const goodStore = internals.store; internals.store = new Proxy(goodStore, { get(target, prop) { if (prop === 'getMessages' || prop === 'appendMessage' || prop === 'getStateJson') throw new Error('transient store failure'); const value = Reflect.get(target, prop); return typeof value === 'function' ? value.bind(target) : value; } }); - await assert.rejects(internals.createConversationAgent('trunk-chan-g1', 'world:chan'), /transient store failure/); + await assert.rejects(internals.createConversationAgent('trunk-chan-g1', { serverId: 'stub', channelId: 'world:chan' }), /transient store failure/); internals.store = goodStore; - const retried = await internals.createConversationAgent('trunk-chan-g1', 'world:chan'); + const retried = await internals.createConversationAgent('trunk-chan-g1', { serverId: 'stub', channelId: 'world:chan' }); assert.equal(retried.name, 'trunk-chan-g1'); await framework.stop(); }); @@ -553,16 +553,16 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [], modules: [] }); const events: string[] = []; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ - resolveLocus: () => 'world:test', - startTyping: (channel: string) => events.push(`start:${channel}`), - stopTyping: (channel?: string) => events.push(`stop:${channel ?? '(all)'}`), - routeSpeech: async (_agent: string, text: string, channelId: string) => { - return { delivered: true, channelId: 'world:test' }; + resolveLocus: () => ({ serverId: 'stub', channelId: 'world:test' }), + startTyping: (channel: { channelId: string }) => events.push(`start:${channel.channelId}`), + stopTyping: (channel?: { channelId: string }) => events.push(`stop:${channel?.channelId ?? '(all)'}`), + routeSpeech: async (_agent: string, text: string, _to: { serverId: string; channelId: string }) => { + return { delivered: true, serverId: 'stub', channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => events.push(`chunk:${delta}`), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => events.push(`complete:${text}`), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - resolveProseTarget: () => ({ channelId: 'world:test' }), + getDefaultPublishChannel: () => ({ serverId: 'stub', channelId: 'world:test' }), isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + resolveProseTarget: () => ({ serverId: 'stub', channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'idle-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); created.contextManager.addMessage('user', [{ type: 'text', text: 'go' }]); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 75f396d3c..34519328f 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -39,10 +39,13 @@ import { MembraneError } from '@animalabs/membrane'; import type { ContentBlock } from '@animalabs/membrane'; import type { ContextInjection } from '@animalabs/context-manager'; +/** A channel on the fixture's discord server. */ +const discord = (channelId: string) => ({ serverId: 'discord', channelId }); + /** Reach the private per-turn routing state (its triggering channel) + the pure channel-deriver. */ function internals(framework: AgentFramework) { return framework as unknown as { - turnRouting: Map; + turnRouting: Map; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>; derivePushEventChannel( origin: Record | undefined, @@ -288,7 +291,7 @@ describe('Trunk channel routing (item-3 redux)', () => { isChannelOpen: () => false, getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), - proseTargetFor: (id: string, serverId?: string) => { asked.push(`${serverId}/${id}`); return '@_reim0n'; }, + proseTargetFor: (c: { serverId: string; channelId: string }) => { asked.push(`${c.serverId}/${c.channelId}`); return '@_reim0n'; }, inboundOnlyServer: () => undefined, stopAll: () => {}, }; @@ -347,7 +350,7 @@ describe('Trunk channel routing (item-3 redux)', () => { getDescriptor: () => ({ label: 'Body · ears', direction: 'inbound' }), getChannelLabel: () => 'Body · ears', proseTargetFor: () => '#ears', - inboundOnlyServer: (id: string) => (id === 'body:ears' ? 'pocket-body' : undefined), + inboundOnlyServer: (channel: { channelId: string }) => (channel.channelId === 'body:ears' ? 'pocket-body' : undefined), stopAll: () => {}, }; i.handleMcplPushEvent({ @@ -391,7 +394,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, - getDescriptor: (id: string) => ({ label: labels[id] }), + getDescriptor: (c: { channelId: string }) => ({ label: labels[c.channelId] }), getChannelLabel: (_serverId: string, id: string) => labels[id], inboundOnlyServer: () => undefined, stopAll: () => {}, @@ -495,10 +498,10 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1, 'the trunk should have run one turn'); - assert.equal( + assert.deepEqual( internals(framework).turnRouting.get('scout')?.triggerChannel, - 'discord:guild:chanA', - 'the turn must be routed to the channel that triggered it', + discord('discord:guild:chanA'), + 'the turn must be routed to the channel that triggered it, on its server', ); await framework.stop(); }); @@ -511,9 +514,9 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1, 'the trunk should wake for the DM'); - assert.equal( + assert.deepEqual( internals(framework).turnRouting.get('scout')?.triggerChannel, - 'discord:dm:42', + discord('discord:dm:42'), 'the DM reply must route to the DM channel, not the global locus', ); await framework.stop(); @@ -528,7 +531,7 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans A' }])); framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(i.turnRouting.get('scout')?.triggerChannel, 'discord:guild:chanA'); + assert.deepEqual(i.turnRouting.get('scout')?.triggerChannel, discord('discord:guild:chanA')); // ...then a DM turn OVERWRITES it — the next turn's reply must never inherit // the previous turn's channel (the concurrency hazard this fix removes). @@ -536,9 +539,9 @@ describe('Trunk channel routing (item-3 redux)', () => { framework.pushEvent(dmPushEvent('42', 'now in a DM')); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2, 'both turns should have run'); - assert.equal( + assert.deepEqual( i.turnRouting.get('scout')?.triggerChannel, - 'discord:dm:42', + discord('discord:dm:42'), 'the routing state must reflect the CURRENT turn’s channel, not chanA', ); await framework.stop(); @@ -549,22 +552,22 @@ describe('Trunk channel routing (item-3 redux)', () => { const i = framework as unknown as { initializeMcpl(servers: unknown[]): Promise; channelRegistry: { - resolveLocus(agentName: string): string | null; - buildChannelContext(agentName?: string): { defaultOutgoing?: { channelId: string } } | undefined; + resolveLocus(agentName: string): { serverId: string; channelId: string } | null; + buildChannelContext(agentName: string | undefined, serverId: string): { defaultOutgoing?: { channelId: string } } | undefined; }; - turnRouting: Map; + turnRouting: Map; }; // The real registry, with no servers: its resolver reads the framework's turn state. await i.initializeMcpl([]); - i.turnRouting.set('scout', { triggerChannel: 'discord:guild:chanA' }); - assert.equal(i.channelRegistry.resolveLocus('scout'), 'discord:guild:chanA', + i.turnRouting.set('scout', { triggerChannel: discord('discord:guild:chanA') }); + assert.deepEqual(i.channelRegistry.resolveLocus('scout'), discord('discord:guild:chanA'), 'the locus a turn freezes comes from its trigger channel'); - assert.equal(i.channelRegistry.buildChannelContext('scout')?.defaultOutgoing?.channelId, 'discord:guild:chanA', - 'and the agent is told the same channel'); + assert.equal(i.channelRegistry.buildChannelContext('scout', 'discord')?.defaultOutgoing?.channelId, 'discord:guild:chanA', + 'and its server is told the same channel'); // A re-pin moved the turn's locus: a later start of the turn tells the // agent where its speech now lands. - i.turnRouting.set('scout', { triggerChannel: 'discord:guild:chanA', locus: 'discord:dm:42' }); - assert.equal(i.channelRegistry.buildChannelContext('scout')?.defaultOutgoing?.channelId, 'discord:dm:42'); + i.turnRouting.set('scout', { triggerChannel: discord('discord:guild:chanA'), locus: discord('discord:dm:42') }); + assert.equal(i.channelRegistry.buildChannelContext('scout', 'discord')?.defaultOutgoing?.channelId, 'discord:dm:42'); await framework.stop(); }); @@ -604,7 +607,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const triggerAtStart: Array = []; framework.onTrace((event) => { if (event.type !== 'inference:started') return; - triggerAtStart.push(internals(framework).turnRouting.get('scout')?.triggerChannel); + triggerAtStart.push(internals(framework).turnRouting.get('scout')?.triggerChannel?.channelId); if (triggerAtStart.length === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back' }])); }); @@ -634,7 +637,7 @@ describe('Trunk channel routing (item-3 redux)', () => { // turn (an InferenceRequest with no channelId). startAgentStream must clear // the entry up front so routeSpeech falls back to the global locus rather // than replaying the stale channel. - i.turnRouting.set('scout', { triggerChannel: 'discord:guild:stale' }); + i.turnRouting.set('scout', { triggerChannel: discord('discord:guild:stale') }); const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise; @@ -725,7 +728,7 @@ describe('Residents read one message slot', () => { type Internals = { addMessage(participant: string, content: ContentBlock[], metadata?: Record, opts?: { forAgent?: string }): string; - turnRouting: Map; + turnRouting: Map; activeTurnTokens: Map; deferredMessages: unknown[]; channelRegistry: unknown; @@ -756,10 +759,10 @@ describe('Residents read one message slot', () => { const i = framework as unknown as Internals; const routed: Array<{ agent: string; text: string; locus: string | null }> = []; const registry: Record = { - resolveLocus: (name: string) => i.turnRouting.get(name)?.triggerChannel ?? 'chan-default', - routeSpeech: async (agent: string, text: string, locus?: string | null) => { - routed.push({ agent, text, locus: locus ?? null }); - return locus ? { delivered: true, channelId: locus } : null; + resolveLocus: (name: string) => i.turnRouting.get(name)?.triggerChannel ?? discord('chan-default'), + routeSpeech: async (agent: string, text: string, locus?: { serverId: string; channelId: string } | null) => { + routed.push({ agent, text, locus: locus?.channelId ?? null }); + return locus ? { delivered: true, serverId: locus.serverId, channelId: locus.channelId } : null; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, @@ -860,7 +863,7 @@ describe('Residents read one message slot', () => { agent: 'shade', run: () => { i.addMessage('Antra', [{ type: 'text', text: 'shade, over here please' }], - { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + { channelId: 'discord:dm:antra', serverId: 'discord', tags: ['chat:addressed'] }); }, }; framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); @@ -906,9 +909,9 @@ describe('Residents read one message slot', () => { // scout's boundary stores it after, re-pinning the turn there. pause.during = async () => { i.addMessage('Antra', [{ type: 'text', text: 'shade, a word' }], - { channelId: 'discord:dm:xenia', tags: ['chat:addressed'] }, { forAgent: 'shade' }); + { channelId: 'discord:dm:xenia', serverId: 'discord', tags: ['chat:addressed'] }, { forAgent: 'shade' }); i.addMessage('Antra', [{ type: 'text', text: 'scout, over here please' }], - { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + { channelId: 'discord:dm:antra', serverId: 'discord', tags: ['chat:addressed'] }); }; framework.pushEvent(residentIncoming('discord:guild:chanA', 'scout, take a look', 'scout', false)); await framework.runUntilIdle(); @@ -935,7 +938,7 @@ describe('Residents read one message slot', () => { let queued = -1; pause.during = async () => { i.addMessage('Antra', [{ type: 'text', text: 'shade, over here please' }], - { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }); + { channelId: 'discord:dm:antra', serverId: 'discord', tags: ['chat:addressed'] }); queued = i.deferredMessages.length; }; framework.pushEvent(residentIncoming('discord:guild:chanC', 'scout, a word', 'scout', false)); diff --git a/test/tune-out-cancel-held.test.ts b/test/tune-out-cancel-held.test.ts index 21c53cf8e..d8172ce61 100644 --- a/test/tune-out-cancel-held.test.ts +++ b/test/tune-out-cancel-held.test.ts @@ -34,6 +34,8 @@ function cancelWith(opts: { backlogCap: number; source: string; stored: Row[]; h const added: Added[] = []; const inferences: string[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, cancelTuneOut: () => ({ params: { epochId: 'eA', cadenceSeconds: 300, backlogCap: opts.backlogCap, maxWakes: 3, startedAtSequence: 0 }, wakeCount: 0, diff --git a/test/tune-out-dump-fit.test.ts b/test/tune-out-dump-fit.test.ts index f5e28317a..dc0d0b2ea 100644 --- a/test/tune-out-dump-fit.test.ts +++ b/test/tune-out-dump-fit.test.ts @@ -46,6 +46,8 @@ function cancelWith(opts: { backlogCap?: number; room?: number; source?: string; params: { epochId: 'eA', cadenceSeconds: 300, backlogCap: opts.backlogCap ?? 20, maxWakes: 3, startedAtSequence: 0 }, wakeCount: 0, }), + // The dump names its channel as the resident is shown it: here its id. + nameShown: (channel: { channelId: string }) => channel.channelId, }; const hooks = { addMessage: (participant: string, content: ContentBlock[], metadata?: Record, forAgent?: string) => { diff --git a/test/tune-out-e2e.test.ts b/test/tune-out-e2e.test.ts index eeb32628f..55a94f564 100644 --- a/test/tune-out-e2e.test.ts +++ b/test/tune-out-e2e.test.ts @@ -211,4 +211,22 @@ describe('tune-out end to end', () => { 'raw stamped originals stay view-excluded permanently', ); }); + + it("a conversation is bound to a channel only on its own server, not another server's channel with the same id", () => { + // Channel ids are unique only within one MCPL server's connection. + const i = internals(framework); + const hooks = (i.tuneOutCoordinator as unknown as { + hooks: { isForkBound(channel: { serverId: string; channelId: string }): boolean }; + }).hooks; + const homes = (framework as unknown as { + conversationAgentHomes: Map; + }).conversationAgentHomes; + homes.set('fork-elsewhere', { serverId: 'other', channelId: CHANNEL }); + try { + assert.equal(hooks.isForkBound({ serverId: 'disc', channelId: CHANNEL }), false, "disc's channel isn't the fork's"); + assert.equal(hooks.isForkBound({ serverId: 'other', channelId: CHANNEL }), true); + } finally { + homes.delete('fork-elsewhere'); + } + }); }); diff --git a/test/tune-out-gate-composition.test.ts b/test/tune-out-gate-composition.test.ts index 24a00d03f..90b37a93b 100644 --- a/test/tune-out-gate-composition.test.ts +++ b/test/tune-out-gate-composition.test.ts @@ -26,6 +26,8 @@ function harness(opts?: { privileged?: boolean }) { const wakes: string[] = []; const acks: string[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, getTuneOutState: () => ({ params: PARAMS, wakeCount: 0 }), recordTuneOutWake: () => ({ params: PARAMS, wakeCount: 1 }), listChannelsRaw: () => [], @@ -64,6 +66,8 @@ describe('duration expiry', () => { it('an expired deadline at resume cancels immediately via the standard flow', () => { const cancels: string[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, listChannelsRaw: () => [{ serverId: 'disc', descriptor: { id: '#a' } }], getTuneOutState: () => ({ params: { ...PARAMS, expiresAtMs: Date.now() - 5_000 }, @@ -96,6 +100,8 @@ describe('duration expiry', () => { it('a live deadline arms a timer that cancels on schedule', async () => { const cancels: string[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, listChannelsRaw: () => [], getTuneOutState: () => null, enterTuneOut: () => {}, @@ -131,6 +137,8 @@ describe('duration expiry', () => { it('enter() stamps expiresAtMs only when durationSeconds is given', () => { const entered: TuneOutParams[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, listChannelsRaw: () => [], getTuneOutState: () => null, enterTuneOut: (_s: string, _c: string, p: TuneOutParams) => { entered.push(p); }, @@ -163,6 +171,8 @@ describe('duration expiry', () => { describe('dispositions injection', () => { function dispositionHarness() { const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, listChannelsRaw: () => [], getTuneOutState: () => null, readCoordinatorState: () => null, diff --git a/test/tune-out-messages.test.ts b/test/tune-out-messages.test.ts index ba25de7c3..12d61b9d8 100644 --- a/test/tune-out-messages.test.ts +++ b/test/tune-out-messages.test.ts @@ -27,6 +27,13 @@ function hooks(opts: { subconscious?: boolean; forkBound?: boolean }) { function coordinator(opts: { tunedOut?: Array<{ channelId: string; expiresAtMs?: number }>; subconscious?: boolean; forkBound?: boolean }) { const tunedOut = opts.tunedOut ?? []; const registry = { + // Each channel's id is unique here, so it's shown by its id, and names + // the one channel with it. + nameShown: (channel: { channelId: string }) => channel.channelId, + channelsNamed: (name: string) => tunedOut.filter((t) => t.channelId === name).map((t) => ({ serverId: 'disc', channelId: t.channelId })), + sharedNameRefusalFor: () => undefined, + // No call here is made inside a turn, so none has own channels. + ownChannelFor: () => undefined, listChannelsRaw: () => tunedOut.map((t) => ({ serverId: 'disc', descriptor: { id: t.channelId } })), getTuneOutState: (_server: string, channelId: string) => { const found = tunedOut.find((t) => t.channelId === channelId); @@ -100,6 +107,153 @@ test("a cancel_tuneout of a channel whose server was removed claims nothing abou }); }); +test("two servers' tune-outs of one channel id are named apart, and cancel_tuneout takes the qualified name", async () => { + // A channel id is unique only within one server's connection, so `a` and `b` can each register `general`. + const registry = new ChannelRegistry({ getServer: () => null } as never, {} as never, () => {}, () => {}); + for (const serverId of ['a', 'b']) { + await registry.handleRegister(serverId, { + channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }], + }); + registry.enterTuneOut(serverId, 'general', SETTINGS, 'agent-tool'); + } + const added: Array<{ text: string; metadata?: Record }> = []; + const wakes: string[] = []; + const coordinator = new TuneOutCoordinator(registry, {} as never, { + ...hooks({}), + addMessage: (_participant: string, content: Array<{ type: string; text?: string }>, metadata?: Record) => { + added.push({ text: content.map((c) => c.text ?? '').join(''), metadata }); + }, + requestInference: (agentName: string, reason: string) => { wakes.push(`${agentName}: ${reason}`); }, + primaryName: () => 'scout', + getStoredMessages: () => [], + getDeferredMessages: () => [], + emitTrace: () => {}, + } as never); + const already = coordinator.enter('b', 'general', {}, 'agent-tool'); + assert.ok(!already.ok && already.error.startsWith('Nothing changed: "b:general" is already tuned out'), JSON.stringify(already)); + // The registry's rule: a name both servers' channels answer to is refused. + assert.deepEqual(await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'general' }), { + success: false, + isError: true, + error: 'Channel "general" is on more than one server ("a:general", "b:general"), so nothing ran. ' + + 'Call again with channelId set to one of them.', + }); + assert.deepEqual(await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'b:general', text: 'quiet now' }), + { success: true, data: { cancelled: true } }); + assert.ok(!registry.getTuneOutState('b', 'general'), "b's tune-out ended"); + assert.ok(registry.getTuneOutState('a', 'general'), "a's goes on"); + // What the resident and the subconscious are told names b's channel apart. + const cancelNotice = added.find((m) => m.metadata?.kind === 'tune-out-cancel'); + assert.ok(cancelNotice?.text.includes('[Tune-out: b:general cancelled'), JSON.stringify(added)); + assert.ok(!added.some((m) => /Tune-out: general\b/.test(m.text)), 'no notice names the bare shared id'); + // The resident's cancel of a's, and a cadence turn on it, wake the + // subconscious with reasons that name a's channel apart. + wakes.length = 0; + assert.deepEqual(coordinator.cancel('a', 'general', 'agent-tool', 'agent request'), { ok: true }); + (coordinator as unknown as { invokeSubconscious(s: string, c: string, t: 'cadence', n: number): void }) + .invokeSubconscious('a', 'general', 'cadence', 2); + assert.deepEqual(wakes.filter((w) => w.startsWith('Subconscious:')), [ + 'Subconscious: tune-out cancelled: a:general', 'Subconscious: tune-out cadence: a:general', + ]); + // The subconscious's own words carry the channel's server, which the + // framework reads on a conversational row (repinChannel). + const note = added.find((m) => m.metadata?.kind === 'subconscious-note'); + assert.deepEqual([note?.metadata?.channelId, note?.metadata?.serverId], ['general', 'b']); + await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'b:general', text: 'all quiet on b' }); + const which = await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'general', text: 'which general?' }); + assert.deepEqual([which.success, which.isError, which.error], [false, true, + 'Channel "general" is on more than one server ("a:general", "b:general"), so nothing was delivered. Call again with channelId set to one of them.']); + await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'nowhere', text: 'about a channel nobody registers' }); + const summaries = added.filter((m) => m.metadata?.kind === 'subconscious-summary').map((m) => [m.metadata?.channelId, m.metadata?.serverId]); + assert.deepEqual(summaries, [['general', 'b'], ['nowhere', undefined]], + 'a shared name is refused with its channels, and a name no channel answers to is kept as given, with no server'); +}); + +test("cancel_tuneout inside the subconscious's turn reads the id of the turn's own channel as that channel, and refuses it once its server withdrew it", async () => { + const registry = new ChannelRegistry({ getServer: () => null } as never, {} as never, () => {}, () => {}); + for (const serverId of ['a', 'b']) { + await registry.handleRegister(serverId, { + channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }], + }); + } + registry.enterTuneOut('a', 'general', SETTINGS, 'agent-tool'); + registry.enterTuneOut('b', 'general', SETTINGS, 'agent-tool'); + const cancelled: string[] = []; + const coordinator = new TuneOutCoordinator(registry, {} as never, hooks({}) as never); + (coordinator as unknown as { cancel(s: string, c: string): { ok: boolean } }).cancel = (s: string, c: string) => { cancelled.push(`${s} ${c}`); return { ok: true }; }; + const own = [{ serverId: 'b', channelId: 'general' }]; + assert.deepEqual(await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'general' }, own), { success: true, data: { cancelled: true } }); + assert.deepEqual(cancelled, ['b general'], "the turn's own channel, not a shared-id refusal"); + await registry.handleChanged('b', { removed: ['general'] }); + const gone = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'general' }, own); + assert.equal(gone.error, '"general" is this turn\'s channel on server "b", which no longer registers it, so nothing ran, ' + + 'and nothing can run on it until that server registers it again.'); + assert.deepEqual(cancelled, ['b general'], "a's tune-out is left as it was"); +}); + +test("speak_in_channel and deliver_summary inside the subconscious's turn read the id of the turn's own channel as that channel", async () => { + const registry = new ChannelRegistry({ getServer: () => null } as never, {} as never, () => {}, () => {}); + for (const serverId of ['a', 'b']) { + await registry.handleRegister(serverId, { + channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }], + }); + } + const posted: unknown[] = []; + (registry as unknown as { publishForAgent(channel: unknown): Promise }).publishForAgent = async (channel: unknown) => { + posted.push(channel); + return { success: true }; + }; + const added: Array | undefined> = []; + const coordinator = new TuneOutCoordinator(registry, {} as never, { + ...hooks({}), + allowChannelSpeech: () => true, + addMessage: (_participant: string, _content: unknown, metadata?: Record) => { added.push(metadata); }, + } as never); + const own = [{ serverId: 'b', channelId: 'general' }]; + await coordinator.handleSubconsciousTool('speak_in_channel', { channelId: 'general', text: 'hello' }, own); + assert.deepEqual(posted, [{ serverId: 'b', channelId: 'general' }], "the turn's own channel, by its ref"); + await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'general', text: 'all quiet' }, own); + assert.deepEqual(added.map((m) => [m?.channelId, m?.serverId]), [['general', 'b']]); + // Once b withdraws it, speech there is refused, and a's general isn't taken for it. + await registry.handleChanged('b', { removed: ['general'] }); + const gone = await coordinator.handleSubconsciousTool('speak_in_channel', { channelId: 'general', text: 'hello again' }, own); + assert.equal(gone.error, '"general" is this turn\'s channel on server "b", which no longer registers it, so nothing was posted, ' + + 'and nothing can be until that server registers it again.'); + assert.equal(posted.length, 1); + // deliver_summary refuses it too, rather than leaving a row a re-pin would read as a's general. + const undelivered = await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'general', text: 'still quiet' }, own); + assert.deepEqual([undelivered.success, undelivered.isError], [false, true]); + assert.equal(undelivered.error, '"general" is this turn\'s channel on server "b", which no longer registers it, so nothing was delivered, ' + + 'and nothing can be until that server registers it again.'); + assert.equal(added.length, 1); + // Where the turn has channels with that id on two servers, it means neither, for both tools. + await registry.handleRegister('b', { channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }] }); + const both = [{ serverId: 'a', channelId: 'general' }, { serverId: 'b', channelId: 'general' }]; + const unspoken = await coordinator.handleSubconsciousTool('speak_in_channel', { channelId: 'general', text: 'to whom?' }, both); + assert.equal(unspoken.error, '"general" is the id of this turn\'s channels on more than one server ("a:general", "b:general"), ' + + 'so nothing was posted. Call again with channelId set to one of them.'); + const unsummed = await coordinator.handleSubconsciousTool('deliver_summary', { channelId: 'general', text: 'about whom?' }, both); + assert.deepEqual([unsummed.success, unsummed.isError], [false, true]); + assert.equal(unsummed.error, '"general" is the id of this turn\'s channels on more than one server ("a:general", "b:general"), ' + + 'so nothing was delivered. Call again with channelId set to one of them.'); + assert.equal(posted.length, 1); + assert.equal(added.length, 1); +}); + +test("cancel_tuneout refuses a name two registered channels answer to even where only one of them is tuned out", async () => { + const registry = new ChannelRegistry({ getServer: () => null } as never, {} as never, () => {}, () => {}); + for (const serverId of ['a', 'b']) { + await registry.handleRegister(serverId, { + channels: [{ id: 'general', type: 'chat', label: '#general', direction: 'bidirectional' }], + }); + } + registry.enterTuneOut('b', 'general', SETTINGS, 'agent-tool'); + const coordinator = new TuneOutCoordinator(registry, {} as never, hooks({}) as never); + const refused = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'general' }); + assert.match(refused.error ?? '', /^Channel "general" is on more than one server \("a:general", "b:general"\)/); + assert.ok(registry.getTuneOutState('b', 'general'), "b's tune-out goes on"); +}); + test("speak_in_channel's refusal names the setting in the host's log, not to the subconscious", async (t) => { const busy = coordinator({ tunedOut: [{ channelId: 'disc:guild:noisy' }] }); const warned = t.mock.method(console, 'warn', () => {}); diff --git a/test/tune-out-timers.test.ts b/test/tune-out-timers.test.ts index d53ff1b92..3d9d6d343 100644 --- a/test/tune-out-timers.test.ts +++ b/test/tune-out-timers.test.ts @@ -70,6 +70,8 @@ function harness( /** The reason of each inference requested, the subconscious's turns among them. */ const inferences: string[] = []; const registry = { + // Each channel's id is unique here, so it's shown by its id. + nameShown: (channel: { channelId: string }) => channel.channelId, listChannelsRaw: () => (state ? [{ serverId: 'disc', descriptor: { id: '#a' } }] : []), getTuneOutState: () => state, enterTuneOut: (_s: string, _c: string, params: TuneOutParams) => { diff --git a/test/tune-out-transport.test.ts b/test/tune-out-transport.test.ts index 2e10e0f4b..b23883a7f 100644 --- a/test/tune-out-transport.test.ts +++ b/test/tune-out-transport.test.ts @@ -22,7 +22,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import type { TuneOutParams } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { McplRequestError } from '../src/mcpl/server-connection.js'; @@ -88,7 +88,7 @@ async function closedChannel() { const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'y' }); assert.equal((closed.data as { status?: string } | undefined)?.status, 'already closed'); server.sent.length = 0; - const isOpen = () => (registry as unknown as { channels: Map }).channels.get('a:y')?.open; + const isOpen = () => (registry as unknown as { channels: Map }).channels.get(destinationKey({ serverId: 'a', channelId: 'y' }))?.open; assert.equal(isOpen(), false); return { registry, servers, server, isOpen, host }; } @@ -200,7 +200,7 @@ test('a tune-out on a server whose channels this host can\'t open says its messa assert.deepEqual(without.sent, []); assert.equal(f.registry.getDesiredState('a', 'y'), 'tuned-out'); // A channel that server has open needs nothing from this host. - (f.registry as unknown as { channels: Map }).channels.get('a:y')!.open = true; + (f.registry as unknown as { channels: Map }).channels.get(destinationKey({ serverId: 'a', channelId: 'y' }))!.open = true; assert.equal(await f.registry.openForTuneOut('a', 'y'), undefined, 'open there'); assert.deepEqual(without.sent, []); }); @@ -679,7 +679,7 @@ test('a tune-out made while a decline\'s acknowledgment is out opens the channel for (let i = 0; i < 5; i++) await tick(); assert.deepEqual(server.sent, ['open y'], 'one open, the tune-out\'s'); assert.equal((declined.data as { status?: string } | undefined)?.status, 'replaced'); - assert.equal((registry as unknown as { channels: Map }).channels.get('a:y')?.open, true); + assert.equal((registry as unknown as { channels: Map }).channels.get(destinationKey({ serverId: 'a', channelId: 'y' }))?.open, true); }); test('a decline of a tuned-out channel is refused, open or not, and changes nothing', async () => { @@ -691,7 +691,7 @@ test('a decline of a tuned-out channel is refused, open or not, and changes noth error: 'Channel "y" is tuned out: its messages go to the subconscious, and channel_decline doesn\'t change that. Call ' + 'tune_out with channelId "y" and mode "cancel" for it to resume normal attention; cancelling delivers the diverted ' + 'backlog.', - data: { refusal: 'tuned-out', channelId: 'y' }, + data: { refusal: 'tuned-out', channelId: 'y', serverId: 'a' }, }; assert.equal(f.isOpen(), false, 'its transport isn\'t open'); assert.deepEqual(await f.registry.handleChannelToolCall('channel_decline', { channelId: 'y', messageId: 'm1', acknowledge: '👀' }), refusal); @@ -713,7 +713,7 @@ test('a close of a tuned-out channel is refused, open or not, and changes nothin error: 'Channel "y" is tuned out: its messages go to the subconscious, and channel_close doesn\'t change that. Call ' + 'tune_out with channelId "y" and mode "cancel" for it to resume normal attention; cancelling delivers the diverted ' + 'backlog, and channel_close closes it after that.', - data: { refusal: 'tuned-out', channelId: 'y' }, + data: { refusal: 'tuned-out', channelId: 'y', serverId: 'a' }, }; const agent = { kind: 'agent' as const, agentName: 'scout' }; assert.equal(f.isOpen(), false, 'its transport isn\'t open'); @@ -744,7 +744,7 @@ test('a close of a tuned-out channel while the tune-out\'s own open is out is re // the channel's queue first would never answer here. const closed = await f.registry.handleChannelToolCall('channel_close', { channelId: 'y' }, { kind: 'agent', agentName: 'scout' }); assert.equal(closed.isError, true); - assert.deepEqual(closed.data, { refusal: 'tuned-out', channelId: 'y' }); + assert.deepEqual(closed.data, { refusal: 'tuned-out', channelId: 'y', serverId: 'a' }); assert.equal(f.registry.getDesiredState('a', 'y'), 'tuned-out'); release(); assert.equal(await opening, undefined, 'the tune-out stands, and its open answers success'); @@ -767,7 +767,7 @@ test('a module\'s close of a tuned-out channel that certifies an idle lease is r error: 'Channel "y" is tuned out: its messages go to the subconscious, and channel_close doesn\'t change that. Call ' + 'tune_out with channelId "y" and mode "cancel" for it to resume normal attention; cancelling delivers the diverted ' + 'backlog, and channel_close closes it after that.', - data: { refusal: 'tuned-out', channelId: 'y' }, + data: { refusal: 'tuned-out', channelId: 'y', serverId: 'a' }, }, ); assert.deepEqual(f.server.sent, [], 'nothing for the transport'); @@ -776,7 +776,7 @@ test('a module\'s close of a tuned-out channel that certifies an idle lease is r // Without the lease, the housekeeping guard refuses it first, as before. const unleased = await f.registry.handleChannelToolCall('channel_close', { channelId: 'y', source: 'subscription-gc' }, module); assert.equal(unleased.isError, false); - assert.deepEqual(unleased.data, { refusal: 'explicit-open', channelId: 'y' }); + assert.deepEqual(unleased.data, { refusal: 'explicit-open', channelId: 'y', serverId: 'a' }); assert.deepEqual(f.server.sent, []); assert.equal(f.registry.getTuneOutState('a', 'y')?.params.epochId, 'e1'); }); @@ -1345,7 +1345,7 @@ test('an agent\'s close of a tuned-out channel is refused, and after the cancel const text = dumped()!.content.map((b) => b.text ?? '').join(''); assert.ok(text.includes('backlog one') && text.includes('backlog two'), text); assert.deepEqual(await f.script('channel_close', { channelId: CHANNEL }), - { text: JSON.stringify({ channelId: CHANNEL, status: 'closed' }), isError: false, refused: false }); + { text: JSON.stringify({ channelId: CHANNEL, serverId: 'disc', status: 'closed' }), isError: false, refused: false }); assert.equal(f.i.channelRegistry.getDesiredState('disc', CHANNEL), 'closed'); }); @@ -1463,7 +1463,7 @@ test('a tune-out on record when a start without servers connects one later ends // The agent tuned it out, so the record stays the agent's: a housekeeping // close with no lease is still refused as an explicit open. const unleased = await registry.handleChannelToolCall('channel_close', { channelId: CHANNEL, source: 'subscription-gc' }, { kind: 'module' }); - assert.deepEqual(unleased.data, { refusal: 'explicit-open', channelId: CHANNEL }); + assert.deepEqual(unleased.data, { refusal: 'explicit-open', channelId: CHANNEL, serverId: 'disc' }); }); test('a tune-out whose ending fails at a start with no subconscious stays recorded, and the log says so', async (t) => { @@ -1497,7 +1497,7 @@ test('a decline of a tuned-out channel whose transport isn\'t open is refused, a } // A transport that isn't confirmed open, as a failed open, a retarget or a // dropped connection leaves it, while the tune-out stands. - (f.i.channelRegistry as unknown as { channels: Map }).channels.get(`disc:${CHANNEL}`)!.open = false; + (f.i.channelRegistry as unknown as { channels: Map }).channels.get(destinationKey({ serverId: 'disc', channelId: CHANNEL }))!.open = false; const declined = await f.script('channel_decline', { channelId: CHANNEL, messageId: 'backlog-one' }); assert.equal(declined.isError, true); assert.equal(declined.refused, false);